Nível 12
Planejar e refletir
- user
- assistant
- tool_result
EventBus
O problema
Dê a um agente um trabalho com várias partes e ele começa pelo que estiver na frente dele. No meio do caminho, os primeiros passos ficaram lá atrás no histórico, e ele esquece um. No fim, responde “pronto!” com total confiança, porque nada o obriga a olhar.
Esse é o Scatterbrain. Duas falhas numa só: sem plano, os passos se perdem; sem checagem, um passo que deu errado é relatado como feito. Na rua Tango, a ferramenta disse com todas as letras que o jornal caiu nos arbustos. O modelo leu e marcou a caixa mesmo assim.
A solução
Primeiro, planeje. Antes da primeira ação de verdade, o agente escreve o trabalho como uma lista
de tarefas, e marca cada uma conforme avança. O truque que faz isso funcionar: o plano inteiro é adicionado a toda
requisição, então o modelo sempre vê o que está feito e o que falta, por mais longo que fique o histórico. No
astorlm, isso é pattern: 'PLAN_EXECUTE': duas ferramentas, add_plan_item e
update_plan_item, e o plano no system prompt a cada turno.
Reflita antes de aceitar. Uma caixa marcada é só o que o modelo diz. Quando o run()
retorna, o seu código revisa o resultado antes de aceitá-lo. Se algo estiver faltando, a constatação volta como uma
nova mensagem na mesma sessão: o agente mantém o histórico e o plano, e corrige só o que está errado.
Limite o número de rodadas. Há três jeitos de revisar, do mais forte ao mais fraco:
-
Conferir o mundo
O seu código olha o resultado em si: as varandas, as linhas do banco de dados, a suíte de testes, o arquivo no disco.
O melhor revisor quando você pode tê-lo: barato, exato, e ninguém consegue convencê-lo de nada. Exige que o trabalho possa ser conferido por código.
-
Um modelo crítico
Uma segunda chamada lê a tarefa, a resposta e uma checklist, e lista o que está errado ou faltando.
Para trabalho que nenhum código consegue conferir: um resumo, um e-mail, um plano. Custa uma chamada, também pode deixar coisas passarem, e precisa de critérios concretos, não de “isto está bom?”.
-
Perguntar ao agente
O system prompt manda o agente reler o trabalho antes de responder.
De graça e, às vezes, suficiente. Mas é o mesmo modelo dando nota a si mesmo, com os mesmos pontos cegos: ele já marcou o #14 uma vez.
É a mesma ideia de uma avaliação do nível 11, usada em tempo de execução: uma avaliação dá nota a execuções depois do fato, para melhorar o agente; uma revisão dá nota a esta execução antes que o usuário a veja.
O elenco
O mesmo elenco de sempre, desta vez numa entrega de jornais.
- A banca o modelo
- O Oráculo, atrás do balcão. Ele decide cada passo, e nunca pedala.
- A folha de rota o plano
- As tarefas e suas caixas. Ela brilha em dourado a cada turno: vai em toda requisição.
- A bike do Astor o loop
- Leva cada chamada de ferramenta, ida e volta, com o histórico no bandoneón.
- Um arremesso deliver
- Uma ferramenta comum. Ela diz onde o jornal caiu.
- O editor o seu código
- Passa o trabalho, e revisa as varandas antes de aceitar a resposta. A lupa dele é o
review().
O código
Com astorlm: pattern: 'PLAN_EXECUTE' adiciona as ferramentas do plano e coloca o
plano em toda requisição; getPlan() o lê de volta. A revisão é código comum depois do
run(), e um segundo run() no mesmo agente continua a mesma sessão.
Do zero: Uma lista, duas ferramentas que a editam, e um system prompt reconstruído com a lista a
cada turno. O histórico vive fora do run(), então a correção continua a mesma conversa.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = { baseURL: 'http://localhost:11434/v1', apiKey: 'YOUR_API_KEY' } // local servers usually ignore the key
const SUBSCRIBERS = [12, 14, 18]
const porches = new Set<number>() // the real world: which porches have a paper
const deliver = tool({
name: 'deliver',
description: 'Ride to a house and throw today’s paper onto its porch. Says where the paper landed.',
schema: z.object({ house: z.number() }),
execute: async ({ house }) => {
const landed = throwPaper(house) // your code: 'porch' or 'bushes'
if (landed === 'porch') porches.add(house)
return landed === 'porch' ? `Paper on the porch at #${house}.` : `Paper landed in the bushes at #${house}.`
},
})
// PLAN: the agent gets add_plan_item and update_plan_item,
// and the current plan is added to the system prompt on every turn.
const agent = await createLocalAgent({
provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
pattern: 'PLAN_EXECUTE',
systemPrompt: 'You deliver newspapers. Plan every stop before you start, and tick each task as you go.',
tools: [deliver],
maxTurns: 20,
})
// REFLECT: check the work itself before accepting the answer. Deterministic when you can;
// a second model with a rubric when you can't.
const review = (): string[] => SUBSCRIBERS.filter((house) => !porches.has(house)).map((house) => `#${house} has no paper on the porch`)
let answer = await agent.run(`Deliver today’s paper to every subscriber on Tango Street: ${SUBSCRIBERS.join(', ')}.`)
for (let round = 1; round <= 2; round++) {
const problems = review()
if (problems.length === 0) break
// Same agent, same session: it keeps its history and its plan, and fixes what's missing.
answer = await agent.run(`Review found: ${problems.join('; ')}. Fix it.`)
}
console.log(agent.getPlan()) // [{ id: '1', description: 'Deliver to #12', status: 'completed' }, …]
console.log(answer.content)
// Plan and reflect, 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
}
const SUBSCRIBERS = [12, 14, 18]
const porches = new Set<number>() // the real world: which porches have a paper
// 1. The plan: a list the model writes and ticks with two tools.
type Task = { id: number; description: string; status: 'pending' | 'completed' }
const plan: Task[] = []
type ToolFn = (args: Record<string, string | number>) => string
const tools: Record<string, ToolFn> = {
add_plan_item: ({ description }) => {
plan.push({ id: plan.length + 1, description: String(description), status: 'pending' })
return `Task added with ID: ${plan.length}`
},
update_plan_item: ({ id, status }) => {
const task = plan.find((t) => t.id === Number(id))
if (!task) return `No task ${id}`
task.status = status === 'completed' ? 'completed' : 'pending'
return `Task ${id} status updated to ${task.status}.`
},
deliver: ({ house }) => {
const landed = throwPaper(Number(house)) // your code: 'porch' or 'bushes'
if (landed === 'porch') porches.add(Number(house))
return landed === 'porch' ? `Paper on the porch at #${house}.` : `Paper landed in the bushes at #${house}.`
},
}
const toolSchemas = [/* one JSON Schema per tool: add_plan_item(description), update_plan_item(id, status), deliver(house) */]
// 2. The plan goes into the system prompt on EVERY turn, so the model never loses track.
const system = (): string =>
'You deliver newspapers. Plan every stop with add_plan_item before you start, and tick each task as you go.\n' +
(plan.length ? plan.map((t) => `- [${t.status}] ${t.description} (id ${t.id})`).join('\n') : '(no plan yet)')
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 }
// The history lives outside run(), so a second run continues the same session.
const messages: Message[] = []
async function run(prompt: string, maxTurns = 20): Promise<string> {
messages.push({ role: 'user', content: prompt })
for (let turn = 1; turn <= maxTurns; turn++) {
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: [{ role: 'system', content: system() }, ...messages], tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const fn = tools[call.function.name]
const output = fn ? fn(JSON.parse(call.function.arguments)) : `Unknown tool: ${call.function.name}`
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// 3. Reflect: check the work itself before accepting the answer, and send what's missing back.
const review = (): string[] => SUBSCRIBERS.filter((house) => !porches.has(house)).map((house) => `#${house} has no paper on the porch`)
let answer = await run(`Deliver today’s paper to every subscriber on Tango Street: ${SUBSCRIBERS.join(', ')}.`)
for (let round = 1; round <= 2; round++) {
const problems = review()
if (problems.length === 0) break
answer = await run(`Review found: ${problems.join('; ')}. Fix it.`)
}
console.log(answer)
# Plan and reflect, 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
}
def post(path, payload):
request = urllib.request.Request(
f"{LLM['base_url']}{path}",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)
SUBSCRIBERS = [12, 14, 18]
PORCHES = set() # the real world: which porches have a paper
# 1. The plan: a list the model writes and ticks with two tools.
PLAN = []
def add_plan_item(description):
PLAN.append({"id": len(PLAN) + 1, "description": description, "status": "pending"})
return f"Task added with ID: {len(PLAN)}"
def update_plan_item(id, status):
task = next((t for t in PLAN if t["id"] == int(id)), None)
if task is None:
return f"No task {id}"
task["status"] = "completed" if status == "completed" else "pending"
return f"Task {id} status updated to {task['status']}."
def deliver(house):
landed = throw_paper(house) # your code: "porch" or "bushes"
if landed == "porch":
PORCHES.add(house)
return f"Paper on the porch at #{house}."
return f"Paper landed in the bushes at #{house}."
TOOLS = {"add_plan_item": add_plan_item, "update_plan_item": update_plan_item, "deliver": deliver}
TOOL_SCHEMAS = [...] # one JSON Schema per tool: add_plan_item(description), update_plan_item(id, status), deliver(house)
# 2. The plan goes into the system prompt on EVERY turn, so the model never loses track.
def system():
lines = [f"- [{t['status']}] {t['description']} (id {t['id']})" for t in PLAN] or ["(no plan yet)"]
return "You deliver newspapers. Plan every stop with add_plan_item before you start, and tick each task as you go.\n" + "\n".join(lines)
# The history lives outside run(), so a second run continues the same session.
MESSAGES = []
def run(prompt, max_turns=20):
MESSAGES.append({"role": "user", "content": prompt})
for _ in range(max_turns):
request = [{"role": "system", "content": system()}, *MESSAGES]
choice = post("/chat/completions", {"model": LLM["model"], "messages": request, "tools": TOOL_SCHEMAS})["choices"][0]
reply = choice["message"]
MESSAGES.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
MESSAGES.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
# 3. Reflect: check the work itself before accepting the answer, and send what's missing back.
def review():
return [f"#{house} has no paper on the porch" for house in SUBSCRIBERS if house not in PORCHES]
answer = run(f"Deliver today's paper to every subscriber on Tango Street: {', '.join(map(str, SUBSCRIBERS))}.")
for _ in range(2):
problems = review()
if not problems:
break
answer = run(f"Review found: {'; '.join(problems)}. Fix it.")
print(answer)
O que observar
- Mantenha as tarefas pequenas e verificáveis. “Entregar no #14” dá para verificar; “cuidar da rua”, não. Uma tarefa que você consegue conferir é uma tarefa que a revisão consegue pegar.
- Deixe o plano mudar. Planos esbarram na realidade: uma rua está fechada, um cliente cancela. O agente deve poder adicionar, remover ou reordenar tarefas, e não seguir uma lista desatualizada.
- Revise o mundo, não o plano. A folha dizia três marcações. Conferir a folha teria passado. A revisão precisa olhar o resultado em si.
- Limite as rodadas. Uma revisão que nunca pode passar, ou um agente que não consegue corrigir o que encontra, fica em loop para sempre. Duas ou três rodadas, e depois passe para uma pessoa (nível 13).
- Não planeje um trabalho de uma linha. Planejar custa turnos e tokens. Para uma única consulta, pule. Compensa quando o trabalho tem vários passos fáceis de perder.