DISPONÍVEL PARA VAGAS FULL-TIME ABERTO A CONTRATOS FREELANCE AI FULLSTACK ENGINEER · AGENTIC AI AGENT-READY · HUMANS WELCOME AI AGENTS · LLM TOOLING · EVALS NEXT.JS · TYPESCRIPT · REACT EM LISBOA · REMOTO PARA O MUNDO
← Todos os posts

Context engineering dentro de um runtime com 344K chats

Um runtime de agentes de IA em produção que ajudei a construir atendeu 2,85M de mensagens em 344K chats com loops de agentes em streaming e conversão de OpenAPI para tools. O contexto era a spec: system prompts viviam no x-mb, tools eram operationIds, e o estado da carteira acompanhava cada request. Código real do BitteProtocol/chat, make-agent e agent-sdk.

Imagem de capa — Context engineering dentro de um runtime com 344K chats

O runtime em números

O runtime rodava loops de agentes em streaming: um usuário enviava uma mensagem, o runtime montava um contexto, chamava o modelo, executava quaisquer tool calls que voltassem, fazia streaming do resultado e repetia até o agente terminar. As tools vinham de specs OpenAPI convertidas em definições de função, que é como os agentes acabaram capazes de chamar serviços reais e montar transações on-chain. Quando saí em outubro de 2025, tinha atendido 2,85M de mensagens em 344K chats. Eu era o segundo committer mais ativo do seu pacote open-source de chat, @bitte-ai/chat.

Nessa escala, o contexto é um pipeline com um orçamento, um modo de falha e uma conta. Mas a maior lição foi arquitetural: nesse sistema, a maior parte do context engineering não acontecia no runtime. Acontecia na spec. Todo snippet abaixo é código real, com o repo e o caminho onde ele vive.

O system prompt vive na spec

Um agente neste sistema era um documento OpenAPI com uma extensão x-mb. A identidade do assistente e todo o seu prompt comportamental eram campos nesse documento. Aqui está a coisa real, do boilerplate do agente, src/app/api/ai-plugin/route.ts em BitteProtocol/agent-next-boilerplate:

"x-mb": {
    "account-id": ACCOUNT_ID,
    email: "youremail@gmail.com",
    assistant: {
        name: "Blockchain Assistant",
        description: "An assistant that answers with blockchain information, tells the user's account id, interacts with twitter, creates transaction payloads for NEAR and EVM blockchains, and flips coins.",
        instructions: "You create near and evm transactions, give blockchain information, tell the user's account id, interact with twitter and flip coins. For blockchain transactions, first generate a transaction payload using the appropriate endpoint (/api/tools/create-near-transaction or /api/tools/create-evm-transaction), then explicitly use the 'generate-transaction' tool for NEAR or 'generate-evm-tx' tool for EVM to actually send the transaction on the client side. For EVM transactions, make sure to provide the 'to' address (recipient) and 'amount' (in ETH) parameters when calling /api/tools/create-evm-transaction. Simply getting the payload from the endpoints is not enough - the corresponding tool must be used to execute the transaction.",
        tools: [{ type: "generate-transaction" }, { type: "generate-evm-tx" }, { type: "sign-message" }],
        categories: ["DeFi", "DAO", "Social"],
        chainIds: [1, 8453]
    },
},

Leia essa string instructions como um context engineer leria. Ela não descreve o agente. Ela sequencia tools: primeiro chame o endpoint que constrói o payload, depois chame a primitiva que o executa, e aqui estão os nomes exatos dos parâmetros que você vai precisar. “Simply getting the payload from the endpoints is not enough” é um bug de produção escrito dentro de um prompt, porque algum autor de agente assistiu o modelo buscar uma transação e declarar vitória sem nunca executá-la.

O array tools ao lado é seleção de tools como declaração. Esse agente recebe generate-transaction, generate-evm-tx e sign-message, as primitivas que o runtime oferece, e nada mais. O que quer que o modelo faça, ele faz dentro dessa cerca.

Validação da spec é validação de contexto

Como a spec era o contexto, validar a spec era o primeiro portão de qualidade. A CLI make-agent fazia isso em uma passada em src/utils/openapi.ts (BitteProtocol/make-agent), buscando o documento uma vez e reusando-o:

export async function validateAndParseOpenApiSpec(
  url: string | URL,
): Promise<XMbSpec | undefined> {
  const specUrl = url.toString();
  const specContent = await fetchWithRetry(specUrl);

  const apiResponse = JSON.parse(specContent);

  await SwaggerParser.validate(apiResponse);

  const xMbSpec = apiResponse["x-mb"];
  if (isXMbSpec(xMbSpec)) {
    return xMbSpec;
  }
  // ...
}

E a checagem do x-mb em src/config/types.ts impunha exatamente os campos que o modelo leria:

const requiredStringFields = ["name", "description", "instructions"] as const;
for (const field of requiredStringFields) {
  if (!assistant[field] || typeof assistant[field] !== "string") {
    return {
      valid: false,
      error: `assistant must contain ${field} as string`,
    };
  }
}

Um agente com instruções vazias falhava o registro, com escândalo, no deploy. Esse é o lugar certo para a falha. A versão ruim desse sistema deixa a spec passar e o modelo improvisa uma personalidade por request.

Descrições são prompt engineering

As tools em si vinham dos paths da spec. O contrato tinha uma linha: o nome de tool que o modelo emite é o operationId da operação. Todo o resto que o modelo sabia sobre uma tool vinha do summary, description e dos schemas de parâmetros da operação, o que significava que escrever esses campos era prompt engineering com passos extras.

A biblioteca compartilhada de parâmetros em @bitte-ai/agent-sdk mostra o que cuidado parece. De packages/agent-sdk/src/openai/params.ts em BitteProtocol/core:

export const addressOrSymbolParam = {
  name: "address",
  in: "query",
  required: true,
  description:
    "The ERC-20 token symbol or address to be sold, if provided with the symbol do not try to infer the address.",
  schema: { type: "string" },
  example: "0x6810e776880c02933d47db1b9fc05908e5386b96",
};

export const amountParam = {
  name: "amount",
  in: "query",
  required: true,
  description: "Amount in human-readable units (not wei)",
  schema: { type: "number" },
  example: 0.123,
};

Ambas as descrições existem porque um modelo errou em produção. “Do not try to infer the address” está aí porque modelos alucinavam felizes endereços de contrato de token a partir de símbolos, e um endereço errado nesse sistema é um prejuízo real, não um fato errado. “(not wei)” está aí porque modelos usam wei por padrão e o endpoint esperava unidades humanas. Cada uma dessas cláusulas é uma cicatriz. Ninguém escreve “not wei” por diversão.

Os campos example também faziam trabalho silencioso. Um endereço concreto no schema ancora o formato de saída do modelo melhor do que qualquer declaração de tipo.

O contexto da carteira acompanha cada request

A coisa que o modelo mais frequentemente precisa em um produto de carteira é a carteira. Agentes ingênuos pedem o endereço ao usuário, o que é uma experiência terrível quando a UI já o conhece. Então o cliente de chat anexava identidade a cada request. O tipo real do body em src/types/types.ts em @bitte-ai/chat:

export interface ChatRequestBody {
  id?: string;
  config?: {
    mode?: string;
    agentId?: string;
    model?: string;
  };
  accountId?: string;
  network?: string;
  evmAddress?: Hex;
  chainId?: number;
  localAgent?: {
    pluginId: string;
    accountId: string;
    spec: BitteOpenAPISpec;
  };
}

accountId, evmAddress e chainId saíam com cada mensagem, então a resposta para “qual é meu saldo” nunca exigiu uma viagem de perguntas de esclarecimento. Essa é a vitória de contexto mais barata que eu conheço: coloque no body o que o usuário de outro modo teria de digitar.

O mesmo contexto seguia a tool call até seu destino. Quando uma tool executava contra um agente local, buildRequestOptions em src/lib/local-agent.ts o anexava como header:

const headers: HeadersInit = {
  "Content-Type": "application/json",
  ...(metadata ? { "mb-metadata": JSON.stringify(metadata) } : {}),
};

O endpoint construindo sua transação sabia de quem era a transação, sem que o modelo jamais serializasse um endereço em seus argumentos.

Resultados de tools são tipados e pequenos

O que volta de uma tool importa tanto quanto o que sai. O contrato de resultado era uma discriminated union, do mesmo arquivo de tipos:

export type BitteToolResult<TResult = unknown> =
  | { data: TResult; error?: never }
  | { data?: never; error: string };

Uma tool ou produzia dados ou produzia uma string de erro legível, nunca ambos, nunca nenhum. A string de erro ia direto para o contexto, o que significava que o modelo podia ler “HTTP error during tool execution: 500” e se recuperar ou reportar, em vez de fazer pattern matching num body vazio.

Duas disciplinas a mais caíram do caminho de execução. Respostas eram parseadas por content type em parseResponse, JSON, texto ou blob, então o modelo lia a resposta real em vez da promessa de uma num schema. E o loop do cliente limitava o agente a maxSteps: 7 na configuração do useChat, o que limitava o crescimento de contexto por request: sete tool calls de resultados, no pior caso, e aí o loop para, quer o modelo se sinta pronto ou não.

Histórico é problema do cliente também

Do lado do servidor, chats eram persistidos como arrays de CoreMessage, o wire format do AI SDK, estendidos com um id de agente:

export type SmartActionMessage = CoreMessage & {
  id?: string;
  agentId?: string;
};

O cliente optava por enviar histórico completo anotado a cada turno com sendExtraMessageFields: true, e reconstruía o estado da UI no load casando resultados de tools com suas chamadas por toolCallId, virando invocações de state: "call" para state: "result". Um chat recarregado mostrava trabalho terminado como terminado.

Aqui preciso ser honesto sobre o limite do que posso mostrar. Como o servidor fechado fazia trim, compactação ou resumo desse histórico não está nos repos abertos, então não vou encenar certeza sobre isso. O que é aberto, e no que trabalhei, é o contrato: histórico completo para fora, mensagens tipadas de volta, contexto de carteira em cada request. O lado do cliente do context engineering acabou sendo metade do trabalho, e é a metade sobre a qual ninguém escreve.

O que o trabalho realmente é

O padrão nessa escala é difícil de desfazer. A qualidade dos agentes acompanhou a spec, não a esperteza de qualquer prompt único. Os agentes que se comportavam eram os com instruções sequenciadas, descrições de parâmetros paranóicas e um array de tools enxuto. Os agentes que alucinavam endpoints e inventavam endereços eram os de specs preguiçosas, e nenhum patch de system prompt na camada de runtime conseguia corrigi-los totalmente, porque o runtime servia fielmente qualquer contexto que a spec definisse.

Então o trabalho nunca foi “escrever um prompt melhor”. Era: fazer a spec virar o prompt, validá-la como código, colocar a carteira no body, tipar os resultados de tools, limitar o loop e tratar cada campo de descrição como uma linha de prompt engineering que será lida dois milhões de vezes. O prompt nunca foi o produto. A spec era.