O Que Vamos Construir
A implementação de referência é um pacote Python pequeno com uma função por módulo:
Uma única pergunta flui por ele assim:
- Perguntar à Venice qual é o modelo atual de function calling, a menos que você tenha fixado um.
- Conectar ao servidor MCP da Apify e listar suas ferramentas.
- Reescrever essas ferramentas MCP como definições de função compatíveis com OpenAI.
- Enviar a pergunta com a lista de ferramentas anexada.
- Se o modelo retornar
tool_calls, executá-los contra a Apify e anexar os resultados como mensagenstool. - Repetir até o modelo responder com texto em vez de uma chamada de ferramenta.
Este agente pode gastar computação da Apify na sua conta. Comece sem
APIFY_TOKEN se você só quer as ferramentas de busca e documentação, e deixe o --yes de fora até realmente pretender rodar Actors.Configurando o Projeto
O projeto de referência usa Python 3.12+ e uv. Crie um novo projeto:httpx2, a linha 2.x do httpx, do qual tanto o openai quanto o mcp já dependem. Instalá-lo diretamente evita acabar com dois clientes HTTP no mesmo ambiente.
Depois crie um arquivo .env:
VENICE_API_KEY vem das configurações de API da Venice. O APIFY_TOKEN vem do Apify Console e é opcional — vamos ver daqui a pouco o que você ganha sem ele.
Carregando a Configuração
As configurações vêm primeiro porque todos os outros módulos as recebem como argumento. Vamos usarpydantic-settings para que variáveis de ambiente, .env e flags da CLI caiam todos em um único objeto validado.
Em src/venice_terminal_agent/config.py, uma classe Settings(BaseSettings) carrega os campos que importam:
venice_model é None em vez de um ID de modelo, e vamos voltar a isso na próxima seção. E max_rounds junto com max_tool_result_chars são os limites que impedem um agente de sair do controle: o primeiro limita quantas rodadas de ferramentas uma pergunta pode consumir, e o segundo limita quanto de uma página raspada é devolvido ao contexto.
A função interessante deste módulo é o construtor de URL:
tools que decide quais ferramentas ele anuncia. Sem um APIFY_TOKEN, pedimos as quatro ferramentas anônimas que funcionam sem autenticação — busca de Actors, detalhes de Actors, busca na documentação e obtenção de documentação. Isso significa que alguém pode clonar o projeto, adicionar apenas uma chave da Venice, e ainda assim ter um agente funcional capaz de pesquisar Actors da Apify. Ele só não consegue rodar nenhum.
Conversando com a Venice
A Venice é compatível com OpenAI, então podemos usar o SDK da OpenAI para as chat completions ehttpx puro para a chamada de descoberta de modelo.
Crie src/venice_terminal_agent/venice.py:
AsyncOpenAI nos dá de graça o helper de streaming e os tool_calls tipados. O cliente httpx puro está lá para os endpoints da Venice que o SDK da OpenAI não conhece, o que neste projeto significa /models/traits.
O timeout do chat é bem mais longo que o timeout da descoberta, de propósito. Uma pergunta que dispara um crawl da web pode legitimamente levar alguns minutos.
Descobrindo um Modelo em Tempo de Execução
Os IDs de modelo da Venice mudam, e fixar um em código é o jeito mais rápido de lançar um agente que quebra em um mês. OGET /models/traits mapeia nomes de traits estáveis para o modelo que atualmente preenche aquele papel, então pedimos por function_calling_default em vez de nomear um modelo:
--model explícita vence, depois VENICE_MODEL do ambiente, e então a consulta ao trait. Assim, o caminho padrão não precisa de nenhuma configuração, mas você ainda pode fixar um modelo quando estiver comparando o comportamento entre dois deles.
Fazendo Streaming das Completions
Agora adicione a chamada de completion:stream() do SDK cuida de ambos: os eventos content.delta alimentam a saída do terminal, e get_final_completion() devolve uma mensagem completa com os tool_calls já costurados.
A requisição em si é construída por uma função separada para continuar fácil de testar:
extra_body é como o SDK da OpenAI repassa campos que ele não modela, e é por ali que os venice_parameters passam. Definir include_venice_system_prompt como false mantém o prompt de assistente padrão da Venice fora da conversa, para que o nosso próprio system prompt seja a única instrução que o modelo recebe. Para um agente com regras estritas de ferramentas, é isso que você quer.
Só anexe tools e tool_choice quando houver pelo menos uma ferramenta. Enviar um array tools vazio é um jeito desnecessário de confundir um modelo.
O módulo também tem um helper format_http_error() que transforma um APIStatusError ou um httpx2.HTTPStatusError em uma string de uma linha com o código de status e o corpo da resposta. Agentes falham na fronteira da API com mais frequência do que em qualquer outro lugar, e uma mensagem legível ali poupa muita adivinhação.
Convertendo Ferramentas MCP em Ferramentas da Venice
Ferramentas MCP e function tools no estilo OpenAI descrevem a mesma coisa em formatos diferentes. Ambas têm um nome, uma descrição e um JSON Schema para os argumentos. A tradução é quase toda mecânica, com uma pegadinha: os nomes das ferramentas da Apify incluem caracteres que nomes de função não permitem. Uma ferramenta de Actor pode se chamarapify/rag-web-browser, e essa barra não é válida.
Então sanitizamos os nomes na saída e mantemos um mapa para poder restaurá-los na volta.
Em src/venice_terminal_agent/tools.py, um ToolCatalog faz a tradução e guarda o mapa:
sanitize_tool_name() substitui caracteres ilegais por hifens, prefixa nomes que começam com dígito e trunca em 64 caracteres. O unique_name() então anexa um sufixo numérico caso essa truncagem tenha feito dois Actors colidirem — o que te poupa de um bug genuinamente confuso em que o modelo chama um Actor e outro diferente é executado. O tool_input_schema() lida com servidores MCP que devolvem um dict, um modelo Pydantic, ou nada.
Formatando Resultados de Volta para o Contexto
Resultados de ferramentas vão direto para a conversa, então eles precisam ser uma string, e precisam de um limite de tamanho. Raspar um site de documentação pode facilmente retornar mais texto do que a janela de contexto comporta. Oformat_tool_result() prefere structured_content quando o servidor o fornece, e senão achata os blocos de conteúdo em texto, lidando com blocos que não são TextContent. Ele termina com as duas linhas que importam:
{"error": "..."} em vez de lançados. Uma chamada de ferramenta que falhou é informação sobre a qual o modelo pode agir — ele pode escolher outro Actor ou corrigir seus argumentos — e ele só consegue fazer isso se a falha chegar até ele como um resultado de ferramenta normal.
Marcando as Ferramentas que Custam Dinheiro
As ferramentas da Apify se dividem de forma limpa em dois grupos: as que leem metadados e documentação, e as que iniciam computação. Queremos confirmação para o segundo grupo, então colocamos o primeiro em uma lista de permissão:Conectando à Apify via MCP
A Apify oferece dois caminhos de entrada. O servidor hospedado emhttps://mcp.apify.com fala Streamable HTTP, e o @apify/actors-mcp-server roda localmente via stdio através do npx. Vamos suportar ambos, já que servem a situações diferentes: o hospedado não precisa de Node.js, e o stdio mantém a conexão na sua própria máquina.
Em src/venice_terminal_agent/apify_mcp.py, uma classe ApifyMcp embrulha a sessão conectada. Seu call_tool() é onde o nome sanitizado é traduzido de volta — a Venice envia apify-rag-web-browser, a Apify recebe apify/rag-web-browser:
client.list_tools(), já que um token com acesso a muitos Actors produz uma lista paginada.
Sendo Dono do Transporte
Uma conexão MCP é um recurso assíncrono de vida longa, e o cliente HTTP embaixo dela também. Um gerenciador de contexto assíncronoApifyMcpSession segura ambos em um AsyncExitStack, escolhe um transporte com base nas configurações e carrega o catálogo. O detalhe que vale copiar é a limpeza:
except BaseException importa mais do que parece. Se listar as ferramentas falhar depois que o transporte subiu, sem ele você vaza um subprocesso ou um socket aberto toda vez que o agente falha ao iniciar.
Aqui estão os dois transportes:
APIFY_TOKEN no seu ambiente, não o seu ambiente de shell inteiro — incluindo sua chave da Venice.
Executando uma Chamada de Ferramenta
A última peça deste módulo,execute_venice_tool_call(), transforma uma chamada de ferramenta da Venice em um resultado em string. Ela embrulha as duas classes de falha — argumentos que não podem ser interpretados e uma chamada à Apify que falhou — como {"error": "..."} em vez de lançar exceção:
{"error": "invalid arguments: ..."} rende uma chamada corrigida na rodada seguinte, enquanto lançar exceção mata a sessão e perde a conversa.
Rodando o Loop de Ferramentas
Agora o agente em si, emsrc/venice_terminal_agent/agent.py. Comece pelo system prompt:
search-actors e fetch-actor-details antes de chamar um Actor desconhecido” existe porque um modelo que chuta o schema de entrada de um Actor desperdiça uma execução paga. A linha sobre ferramentas recusadas existe porque, sem ela, o modelo trata uma recusa como um erro transitório e tenta de novo imediatamente.
A classe Agent recebe os dois clientes, um modelo, um limite de rodadas e três callbacks:
on_tool reporta uma chamada de ferramenta, o on_text recebe os tokens em streaming, e o approve_tool responde à pergunta de confirmação. Troque-os e o mesmo agente funciona por trás de um app web ou de um chat bot.
Aqui está o loop:
start e o del no handler de exceção merecem um olhar mais atento. Se uma pergunta falha no meio do caminho — erro de rede, Ctrl+C, limite de rodadas —, a conversa fica com um turno do assistente pedindo ferramentas que nunca produziram resultados. A Venice vai rejeitar a próxima requisição, porque um turno com tool_calls deve ser seguido pelas mensagens tool correspondentes. Reverter até onde a pergunta começou significa que uma pergunta que falhou não deixa rastro e o REPL continua utilizável.
Ecoando o Turno do Assistente
Esta próxima função é pequena e fácil de errar:message.model_dump(exclude_none=True), e ela quebra o tool calling. Um turno de chamada de ferramenta tem content: null, e derrubar essa chave muda o formato da mensagem que você envia de volta. O exclude_unset=True é a versão que você quer: ele mantém os valores null que o modelo realmente definiu e omite os campos que ele nunca enviou.
Ele também preserva campos que o schema da OpenAI não conhece. Modelos de raciocínio retornam reasoning_content e reasoning_details, e esses precisam sobreviver à ida e volta para que o modelo mantenha sua própria cadeia de pensamento entre as rodadas de ferramentas.
Executando e Controlando as Chamadas
Modelos podem pedir várias ferramentas em um turno, e não há razão para executá-las uma de cada vez. Mas queremos pedir aprovação sequencialmente, já que prompts de confirmação intercalados seriam ilegíveis. Então planejamos primeiro e executamos concorrentemente depois:tool. Todo tool_call_id precisa de uma resposta, e pular um deixa a conversa malformada. A resposta apenas explica que o usuário disse não.
A checagem de aprovação em si consulta os dois nomes, já que o modelo trabalha com nomes sanitizados e nossa lista de permissão usa nomes MCP:
Adicionando a CLI
A CLI emsrc/venice_terminal_agent/cli.py é Typer mais um REPL, e é o arquivo menos interessante do projeto — mas três detalhes nele valem ser copiados.
O primeiro é que as opções do Typer são tipadas como opcionais e têm None como padrão, para que o carregador de configurações possa distinguir “não passado” de “passado um valor falsy”:
None são o que torna a passagem para load_settings() segura, já que uma flag que você não usou nunca sobrescreve o ambiente:
yes or None é a mesma ideia aplicada a uma flag booleana: --yes a define, e omiti-la passa None em vez de False, então o AUTO_APPROVE_TOOLS do ambiente sobrevive.
O segundo é a ordem de inicialização. Resolva o modelo, depois abra a sessão MCP, depois construa o agente — e feche o cliente da Venice em um finally, já que a sessão MCP e os clientes HTTP precisam ser desmontados independentemente de a pergunta ter dado certo ou não:
isatty() é a parte que as pessoas esquecem. Rode o agente a partir de cron ou CI e não há ninguém para responder ao prompt, então uma implementação ingênua ou trava para sempre ou aprova silenciosamente. Aqui ela recusa, explica por quê, e deixa o modelo seguir com as ferramentas somente-leitura. O default=False significa que um Enter perdido não inicia uma execução paga, e interromper o prompt conta como um não.
O resto do módulo é trabalho de terminal comum, então vale mais saber o que está lá do que lê-lo: um loop de REPL com prompt_toolkit, um lookup _handle_command() para os comandos de barra, um render.py com helpers do Rich, e um _settings_error() que transforma uma VENICE_API_KEY ausente em uma mensagem legível em vez de um traceback do Pydantic. Três dessas peças carregam uma decisão:
Os comandos de barra são
/help, /clear, /quit, e dois que fazem por merecer: /tools imprime o catálogo carregado, o que geralmente explica por que o agente escolheu uma ferramenta estranha, e /reload capta Actors que você adicionou à sua conta Apify no meio da sessão.
Por fim, conecte o ponto de entrada no pyproject.toml para que uv run venice-agent funcione:
Rodando o Agente
Inicie uma sessão interativa:APIFY_TOKEN não foi carregado, e é muito melhor perceber isso agora do que depois de dez minutos se perguntando por que o agente se recusa a rodar um Actor.
Restrinja o catálogo de ferramentas quando você souber do que precisa:
--tools é o jeito mais barato de estreitar a escolha.
Rode o servidor MCP localmente em vez de usar o hospedado:
@apify/actors-mcp-server via npx, e precisa de um APIFY_TOKEN — não há modo anônimo para o servidor local.
E quando você genuinamente quiser execuções de Actor sem supervisão:
Testando as Peças
Nada da lógica interessante aqui precisa de rede. UmFakeVenice que retira respostas de uma lista pré-programada, mais um FakeApify que constrói um ToolCatalog real a partir de ferramentas SimpleNamespace, é suficiente para conduzir uma rodada completa de ferramentas:
agent.messages do mesmo jeito. Que uma execução falha reverte o histórico para apenas ["system"], tanto se ela falhou por um erro da Venice quanto por esgotar max_rounds. Que uma ferramenta somente-leitura ainda roda quando o aprovador retorna False. E que uma ferramenta paga recusada deixa uma mensagem tool contendo declined enquanto apify.calls fica vazio.
Rode a suíte com:
Notas de Privacidade e Custo
Um agente que alcança duas APIs merece precisão sobre isto:
A retenção zero de dados da Venice cobre o lado do modelo. Ela não cobre a Apify, e uma execução de Actor grava resultados na sua conta Apify. Se isso importa para uma tarefa específica, rode sem
APIFY_TOKEN e fique nas ferramentas anônimas de descoberta.
Sobre custo, três hábitos rendem muito:
- Deixe o
--yesde fora durante o desenvolvimento. Observar quais Actors o modelo quer rodar é informativo por si só. - Use
--toolspara estreitar o catálogo aos Actors que você realmente revisou. - Mantenha
max_roundsmodesto. Doze rodadas são mais que suficientes para tarefas de pesquisa, e um teto mais baixo limita o estrago quando um modelo trava em um loop.
Estendendo Este Exemplo
O loop é a fundação. Depois que ele funcionar, direções úteis incluem:- Adicionar um segundo servidor MCP. Nada no
Agenté específico da Apify, então mesclar catálogos de vários servidores é, na maior parte, uma questão de dar namespaces aos nomes das ferramentas. - Persistir conversas em SQLite para poder retomar uma sessão ou auditar o que um Actor retornou.
- Adicionar orçamentos por ferramenta que rastreiam execuções de Actor e param em um teto, em vez de confirmar cada uma.
- Cachear resultados de ferramentas por nome e argumentos, para que consultas repetidas à documentação não refaçam o crawl.
- Fixar um modelo com
--modele comparar a qualidade da seleção de ferramentas contra ofunction_calling_default. - Trocar o aprovador por uma função de política que aprova automaticamente Actors específicos com argumentos específicos e pergunta sobre todo o resto.