Nível 2
O loop do agente
- user
- assistant
- tool_result
EventBus
O problema
Uma mamãe pássaro pergunta a um modelo "você consegue limpar o forte dos porcos?". O modelo não consegue lançar
nada. O máximo que ele pode fazer é responder com um pedido de ferramenta:
{ name: "launch_red", input: { angle: 40 } }.
Se o seu código faz uma única chamada ao modelo, a conversa termina ali. Você fica com um pedido que ninguém executou e sem resposta. Se você executa a ferramenta na mão, bate no mesmo problema na rodada seguinte, porque o modelo pode precisar de outra ferramenta, e depois de mais outra.
A solução
Um loop com uma única regra de saída:
- Envie ao modelo o histórico inteiro mais a lista de ferramentas disponíveis.
- Se a resposta terminar com
stopReason: "end_turn", devolva-a. Essa é a única saída normal. -
Se terminar com
"tool_use", execute cada ferramenta pedida, acrescente os resultados ao histórico como blocostool_resulte volte ao passo 1.
O modelo decide o que fazer; o loop é quem faz. Essa divisão é a base de todos os outros padrões: todo o resto (steering, compactação de contexto, subagentes) se encaixa em algum ponto deste loop.
O elenco
O loop contado como uma pequena aventura. Depois que você conhece o elenco, não sobra nada para decifrar.
- Astor o loop
- Um pequeno tanguero, e o único que se mexe. Leva a pergunta ao Oráculo, corre até o estilingue a cada tiro e traz a resposta de volta para a mamãe pássaro.
- O Oráculo Provider
- O modelo. Nunca toca no estilingue: só escuta o bandoneón e devolve um bilhete. Laranja se precisa de uma ferramenta, dourado se terminou.
- O bandoneón messages[]
- O histórico, uma dobra colorida por mensagem. O fole cresce a cada volta, e o Oráculo escuta cada dobra toda vez. São as notas que sobem flutuando até o Oráculo.
- O banco ToolRegistry
-
Um pássaro por ferramenta:
launch_red,launch_bombe um terceiro de que ninguém precisa hoje. Astor lança o que o bilhete indica e mostra o resultado: verde se funcionou. - A mamãe pássaro agent.run()
- O seu código. Faz a pergunta e espera.
- Rastros, pontuação e pássaros histórico, tokens, maxTurns
- Cada tiro deixa seu rastro no céu, do mesmo jeito que o histórico guarda cada resultado. A pontuação são os tokens, e ela pula mais a cada volta porque o histórico inteiro é enviado de novo. Cada volta custa um pássaro da fileira da barra superior, o orçamento de turnos. Os números são ilustrativos.
O painel EventBus mostra os eventos que o loop real emite em cada passo da animação.
O código
Com astorlm: O mesmo loop vive em src/agent/loop.ts, com streaming, novas
tentativas, hooks, execução de ferramentas em paralelo e cancelamento. Visto de fora, ele fica assim.
Do zero: Umas 40 linhas contra qualquer endpoint compatível com a OpenAI, sem SDK:
fetch puro em TypeScript, a biblioteca padrão em Python. Os três passos acima estão marcados nos
comentários. Preencha o bloco LLM do início com o seu próprio endpoint, modelo e chave.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
// Your game's functions, wrapped as tools: one per bird.
const angle = z.number().min(10).max(80).describe('Launch angle in degrees')
const launchRed = tool({
name: 'launch_red',
description: 'Fling the red bird. Good against wood. Returns what fell and how many pigs are left.',
schema: z.object({ angle }),
execute: async ({ angle }) => level.fling('red', angle), // your code
})
const launchBomb = tool({
name: 'launch_bomb',
description: 'Fling the bomb bird. It explodes on impact: the one to use against stone.',
schema: z.object({ angle }),
execute: async ({ angle }) => level.fling('bomb', angle), // your code
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [launchRed, launchBomb],
maxTurns: 10, // the birds in line: a cap on the laps
})
agent.on('tool-start', (tool) => console.log('→', tool.name, tool.input))
const answer = await agent.run('The pigs took our eggs! Can you clear their fort?')
// Agent loop from scratch. Plain fetch, no SDK.
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
type ToolFn = (args: Record<string, string>) => Promise<string>
const tools: Record<string, ToolFn> = { launch_red: launchRed, launch_bomb: launchBomb }
const toolSchemas = [/* one JSON Schema per tool */]
export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
const messages: Message[] = [{ role: 'user', content: prompt }]
for (let turn = 1; turn <= maxTurns; turn++) {
// 1. Send the whole history plus the tool list.
const res = await fetch(`${LLM.baseURL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
// 2. No tool calls: the model is done. The only normal exit.
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
// 3. Run each requested tool and feed the result back as a message.
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
if (run) {
try {
output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
# Agent loop from scratch. Standard library only, no SDK.
import json
import urllib.request
# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
"base_url": "http://localhost:11434/v1", # e.g. Ollama's default address
"model": "your-model", # e.g. "llama3.1", "gpt-4o-mini"
"api_key": "YOUR_API_KEY", # local servers usually ignore it
}
TOOLS = {"launch_red": launch_red, "launch_bomb": launch_bomb}
TOOL_SCHEMAS = [...] # one JSON Schema per tool
def chat(messages):
request = urllib.request.Request(
f"{LLM['base_url']}/chat/completions",
data=json.dumps({"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS}).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)["choices"][0]
def run_agent(prompt, max_turns=10):
messages = [{"role": "user", "content": prompt}]
for _ in range(max_turns):
# 1. Send the whole history plus the tool list.
choice = chat(messages)
reply = choice["message"]
messages.append(reply)
# 2. No tool calls: the model is done. The only normal exit.
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
# 3. Run each requested tool and feed the result back as a message.
for call in reply.get("tool_calls", []):
name = call["function"]["name"]
run = TOOLS.get(name)
try:
output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
except Exception as err:
output = f"Error: {err}"
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
Repare que um erro de ferramenta não para o loop: ele volta para o modelo como texto, para que ele possa se corrigir na rodada seguinte.
Quando usar, e o que observar
Sempre que o modelo precisar agir (ler, buscar, executar algo) antes de poder responder. Se você só precisa transformar texto, uma única chamada basta, e sai mais barato.
- O custo cresce a cada volta. Observe o bandoneón e o contador de tokens: o Oráculo escuta cada dobra a cada turno, então cada volta reenvia tudo o que veio antes. Com turnos suficientes, o contexto enche.
-
Sempre defina
maxTurns. Um modelo confuso pode continuar pedindo ferramentas para sempre. Sem um limite, o loop também roda para sempre.