Ir para o conteúdo
Trilha
Capítulos

Aprender

IA e agentes

Chame um modelo, dê ferramentas a um agente, transfira a conversa entre agentes, use e exponha MCP, e transmita a resposta em streaming.

O pacote ai fala o protocolo de chat completions da OpenAI, que hoje é a língua franca dos provedores: OpenAI, Groq, Mistral, OpenRouter, Ollama, LM Studio e vLLM aceitam as mesmas requisições. Você configura a URL e o modelo por variáveis de ambiente e o código não muda. Como todo o Trilha, ai e ai/mcp não trazem dependências fora da biblioteca padrão.

export OPENAI_API_KEY=sk-...                 # ou qualquer token do seu provedor
export OPENAI_BASE_URL=http://localhost:11434/v1   # Ollama local, por exemplo
export TRILHA_AI_MODEL=qwen2.5:7b

Uma chamada#

cli := ai.NewFromEnv()
resp, err := cli.Chat(ctx, ai.Request{Messages: []ai.Message{
    ai.System("Responda em uma frase."),
    ai.User("O que é um layout no Trilha?"),
}})
fmt.Println(resp.Text())

Stream entrega a resposta aos pedaços; Delta.Content traz o texto e Delta.ToolCalls os argumentos de ferramentas conforme chegam.

Ferramentas#

Uma ferramenta é um nome, uma descrição, um JSON Schema para os argumentos e uma função Go. ai.Typed decodifica os argumentos em uma struct para você:

clima := ai.NewTool("clima", "Temperatura atual em uma cidade.",
    ai.Schema(`{"type":"object","properties":{"cidade":{"type":"string"}},"required":["cidade"]}`),
    ai.Typed(func(ctx context.Context, in struct{ Cidade string }) (string, error) {
        return buscarTemperatura(ctx, in.Cidade)
    }))

Erros e pânicos dentro da ferramenta viram texto para o modelo ("error: ..."), nunca derrubam o servidor. O modelo lê o erro e decide o que fazer, que é o comportamento que você quer em um agente.

Agentes#

Um agente é instruções + ferramentas. ai.Run executa o laço modelo → ferramentas → modelo até a resposta final (ou MaxTurns, padrão 10). Chamadas de ferramentas na mesma rodada rodam em paralelo.

assistente := &ai.Agent{
    Name:         "Assistente",
    Instructions: "Responda em português, de forma curta.",
    Tools:        []*ai.Tool{clima},
}
res, err := ai.Run(ctx, cli, assistente, "Está frio em Curitiba?")
fmt.Println(res.Output)        // texto final
fmt.Println(res.Steps)         // cada ferramenta chamada, com argumentos e saída

res.Messages é a conversa inteira; passe-a como histórico na próxima chamada para manter o contexto: ai.Run(ctx, cli, assistente, "E amanhã?", res.Messages...).

Um agente que age sobre os seus dados — ferramentas que leem o que o visitante pode ler, uma escrita que espera uma pessoa, o rastro do que ele fez — é a receita do agente de IA.

Multiagentes#

Três formas de compor agentes, da mais simples à mais controlada:

  • Handoff: Handoffs: []*ai.Agent{tradutor} cria a ferramenta transfer_to_tradutor. Quando o modelo a chama, o tradutor assume a conversa: as instruções trocam, o histórico fica. É o padrão "triagem → especialista".
  • Agente como ferramenta: pesquisador.AsTool(cli, "Pesquisa um tema") faz o agente principal chamar o outro como uma função e continuar ele mesmo a conversa.
  • Orquestração em Go: ai.Parallel roda vários agentes de uma vez e ai.Chain passa a saída de um como entrada do próximo. Você fica com o controle no código, sem depender de o modelo "lembrar" de delegar.

Qual das três escolher — e uma triagem que repassa para o financeiro, rodando — está na receita do agente de IA.

Streaming até o navegador#

c.Stream() transforma a resposta em Server-Sent Events e ai.RunStream entrega os eventos do agente (texto, chamada de ferramenta, resultado, handoff, fim):

func POST(c *trilha.Ctx) error {
    var in struct{ Message string; History []ai.Message }
    if err := c.BindJSON(&in); err != nil { return err }
    s := c.Stream()
    _, err := ai.RunStream(c.Context(), cli, assistente, in.Message, func(ev ai.Event) {
        switch ev.Type {
        case "text":
            _ = s.Send("text", ev.Text)
        case "done":
            _ = s.JSON("done", map[string]any{"history": ev.Result.Messages})
        }
    }, in.History...)
    return err
}

No cliente, um fetch com POST e leitura do corpo com ReadableStream basta (o EventSource do navegador só faz GET). Esse é o laço escrito à mão — vale ler uma vez, e depois não escrever de novo.

Um chat pronto#

Todo app com assistente acaba escrevendo aquela rota e aquele script. O ai.Serve e o ui.Chat são as duas metades já escritas:

// app/api/chat/route.go
func POST(c *trilha.Ctx) error {
    return ai.ServeOpts{HTML: ui.ChatHTML}.Serve(c, cli, assistente)
}

// a página
ui.Chat(c, ui.ChatOpts{Action: "/api/chat", History: msgs, Steps: true})
ui.ChatScript(c)

O ai.Serve lê a mensagem, roda o agente e emite os eventos com os nomes acima; o ui.Chat desenha as bolhas, o campo e o aria-live, e o ui.chat.js lê o fluxo. Sem JavaScript o formulário submete do mesmo jeito e a mesma rota responde tudo de uma vez — a tela nunca depende do script rodar.

Texto de modelo é Markdown, e quem põe isso na tela é o ui.Markdown: um <script> na resposta é texto na página, não uma tag no documento. O histórico é do app; o framework não guarda sessão de conversa.

Tudo isso montado — a chave, a configuração que o administrador muda, o contexto da página, o histórico, o limite e o teste que roda sem chave — é a receita do chat de IA, que termina numa demo rodando.

Voz#

Nem sempre quem chega ao balcão vai digitar. Gravar, transcrever, responder e falar de volta são quatro passos, e três deles são o mesmo protocolo do chat.

// a página
ui.Recorder(c, ui.RecorderOpts{Action: "/api/voz", Target: "resposta", MaxSeconds: 60})
ui.RecorderScript(c)
ui.UploadScript(c)

// app/api/voz/route.go — gravar → transcrever → responder
up, err := c.File("audio", trilha.FileRules{Accept: []string{"audio/*", "video/webm"}})
if err != nil {
    return err
}
defer up.Close()
t, err := cli.Transcribe(c.Context(), up, ai.TranscribeOpts{Language: "pt", Filename: up.Name})
res, err := cli.Chat(c.Context(), ai.Request{Messages: []ai.Message{
    {Role: "system", Content: "Responda em crioulo haitiano, curto e falado."},
    {Role: "user", Content: t.Text},
}})
return c.Render(200, resposta(c, t.Text, res.Text()))

// app/api/voz/fala/route.go — falar, e o ui.Audio toca
body, err := cli.Speak(c.Context(), c.Query("texto"), ai.SpeakOpts{Voice: "nova"})
if err != nil {
    return err
}
defer body.Close()
return c.Inline("resposta.mp3", body, ai.SpeakOpts{}.ContentType())

O ui.Recorder grava com o MediaRecorder, põe a gravação no campo de arquivo e envia o formulário, então o upload com progresso e a troca do #resposta são os que você já tem. O que sobra sem JavaScript, sem MediaRecorder ou com o microfone negado é o <input type="file" accept="audio/*" capture> que a marcação sempre carrega — no celular, o gravador do sistema. A rota é escrita uma vez, contra o c.File.

O microfone exige HTTPS e uma política que o libere; o padrão do framework nega:

a.Config().Security.PermissionsPolicy = "camera=(), microphone=(self), geolocation=(), payment=(), usb=()"

A CSP não muda. Tradução não é um recurso à parte — é a mensagem de sistema ali em cima — e o SpeechRecognition do navegador está fora: não está em todo navegador, manda o áudio para quem o navegador escolheu e deixa o servidor sem nada para registrar. A gravação chega na sua rota, e o que acontece com ela é seu. Tudo isso roda no examples/assistente, na página /voz.

MCP: usar e expor ferramentas#

O Model Context Protocol padroniza como hosts (Claude, Cursor, VS Code...) descobrem e chamam ferramentas. O Trilha implementa os dois lados.

Cliente: as ferramentas de qualquer servidor MCP viram *ai.Tool para os seus agentes.

fs, err := mcp.Dial(ctx, mcp.Stdio("npx", "-y", "@modelcontextprotocol/server-filesystem", "."))
tools, err := fs.Tools(ctx)
agente.Tools = append(agente.Tools, tools...)

mcp.HTTP(url, headers) conecta a servidores remotos (Streamable HTTP).

Servidor: as ferramentas do seu app ficam disponíveis para hosts externos com uma rota:

// app/mcp/route.go
var servidor = mcp.NewServer("meu-app", "1.0", clima, buscarPedido)

func POST(c *trilha.Ctx) error { return servidor.ServeHTTP(c) }

Proteja a rota como qualquer API (middleware com token, limite de taxa). O servidor emite Mcp-Session-Id no initialize e rejeita mensagens sem sessão. Para hosts que preferem stdio, servidor.ServeStdio(ctx, os.Stdin, os.Stdout) em um main separado.

A API que você já tem não precisa de ferramentas escritas à mão. mcp.FromRoutes(a, opts) publica cada rota sob /api/ como ferramenta — nome, descrição e esquema do documento OpenAPI, a chamada passando pela cadeia da própria rota, o tools/list filtrado pelo que a chave de quem chama alcança. A receita é A sua API como ferramentas de agente; os detalhes, em mcp.

Seu projeto explicado para um agente#

Os capítulos acima falam do agente que a sua aplicação executa. Esta seção fala do agente que edita a sua aplicação — Claude Code, Cursor, Copilot — e do arquivo que ele lê primeiro.

trilha agents             # num projeto que já existe
trilha new loja --agents  # já na criação

O --agents é flag do new; num projeto que já existe o comando é o trilha agents, e a atualização vinda de uma versão anterior tem cinco linhas na receita de migração.

Ele grava três arquivos. O AGENTS.md é do framework: as três convenções, os comandos e o que cada um verifica, as dez regras de como o código é escrito no projeto, e o que não fazer (editar trilha_gen.go, acrescentar dependência, pôr segredo no código). As regras são as que uma revisão teria de repetir: nada de render à toa, carregar só o que a página usa, cache e versão em toda requisição, nenhuma consulta dentro de laço e índice para todo filtro, imagem com tamanho, padrões só onde tiram código, código limpo, reúso primeiro, o menor diff legível, e OWASP ASVS e NIST SSDF por padrão. O checklist delas — o que fazer e o que procurar — é o .claude/skills/trilha-quality/SKILL.md, que o Claude Code carrega como skill e outro agente lê pelo link. O CLAUDE.md é seu: três linhas apontando para o AGENTS.md e espaço para o que este repositório precisar.

Os limites que as regras dizem também são cobrados: o trilha agents instala um hook de pre-commit que barra o commit com um arquivo acima de 5.000 tokens, ou um arquivo de código acima de 500 linhas, e diz como dividir (trilha hooks e trilha precommit).

Nenhum dos dois existe sem que você peça. Suporte a agentes é escolha do time, não convenção do framework, então trilha new sozinho deixa seu projeto exatamente como deixava antes.

O AGENTS.md é atualizado como o kit ui: ele leva o hash do próprio corpo, então uma cópia intocada de uma versão anterior é regravada em silêncio e uma que você editou pede --force. Acrescente suas regras nele e elas sobrevivem à próxima atualização — o comando recusa em vez de sobrescrever.

Dois comandos existem para esse leitor em particular. O trilha ctx imprime o mapa do projeto — cada rota com seu arquivo e seus métodos, cada operação de API com o que recebe e o que devolve, os tipos envolvidos, o que o app/setup.go provê — numa leitura só, em vez de uma dúzia de arquivos abertos, com --json quando quem lê é uma ferramenta. O trilha check é o portão único antes de dizer que terminou: gen, gofmt, vet, test, audit e openapi num comando, parando na primeira falha, com --fix para os dois problemas que ninguém devia precisar ouvir duas vezes. Todo problema que ele reporta vem com o arquivo, a linha e a frase que resolve, então descobrir isso não custa uma ida e volta a mais. Os dois estão na CLI.

Desafio#

Dê ao agente do exemplo uma ferramenta buscar_post que consulte a API do blog (/api/posts/{id}) e peça: "resuma o post ola-trilha".

Mostrar solução
buscarPost := ai.NewTool("buscar_post", "Busca um post do blog pelo slug.",
    ai.Schema(`{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"]}`),
    ai.Typed(func(ctx context.Context, in struct{ Slug string }) (string, error) {
        p, ok := posts.BySlug(in.Slug)
        if !ok {
            return "", fmt.Errorf("post não encontrado: %s", in.Slug)
        }
        return p.Title + "\n\n" + p.Body, nil
    }))
assistente.Tools = append(assistente.Tools, buscarPost)

Por ser uma chamada em processo, não há HTTP nem chave: a ferramenta lê o repositório direto. Quando a fonte é externa, use o ctx para respeitar o cancelamento do cliente.