Nível 13
Humano no loop
- user
- assistant
- tool_result
- tool_result (erro)
EventBus
O problema
Um agente que roda sem uma pessoa é rápido até o momento em que faz algo que ninguém queria: paga a fatura errada, manda e-mail para todos os clientes, gasta a última ficha no prêmio errado. E um agente que para para perguntar sobre tudo não é mais rápido do que fazer você mesmo.
Esse é o Autopilot Rex: nunca pergunta, nunca espera, nunca atende um chamado. Ele se agarra ao primeiro plano, ignora o que mudou desde então e chuta quando deveria ter perguntado.
A solução
Coloque uma pessoa nos poucos pontos em que o julgamento dela importa, e deixe o agente rodar em todos os outros. São três pontos, e eles se diferenciam por quem quebra o silêncio:
-
Aprovar
O agente espera
Antes de uma ferramenta irreversível rodar, um hook mostra à pessoa exatamente o que ela vai fazer e espera um sim ou um não.
Pagamentos, exclusões, mensagens a clientes, a última ficha: qualquer coisa que não dá para desfazer.
-
Corrigir o rumo
A pessoa interrompe
A pessoa coloca uma correção na fila quando quiser. Na próxima chamada de ferramenta, o loop cancela essa chamada e entrega a correção ao modelo no lugar.
Alguém está olhando e muda de ideia, ou vê o agente indo na direção errada.
-
Escalar
O agente pergunta
Uma ferramenta como ask_human que só retorna quando uma pessoa responde. O modelo decide quando usá-la.
Falta informação, duas opções igualmente boas, pouca confiança. O system prompt diz quando perguntar.
Aprovar e corrigir o rumo vivem no hook beforeToolExecution do nível 6: ele roda antes de toda
ferramenta, e pode esperar. Numa aprovação, ele espera a resposta da pessoa; numa correção de rumo, confere se há
uma correção na fila. Nos dois casos, o que a pessoa disse volta para o modelo como resultado da ferramenta, então
a execução segue com a informação nova em vez de quebrar.
O elenco
O mesmo elenco de sempre, desta vez no fliperama.
- A cartomante o modelo
- O Oráculo, na cabine. Lê o histórico e decide a próxima chamada. Nunca encosta na máquina.
- Os controles as ferramentas
-
move_clawé de graça e pode ser desfeito.drop_clawgasta a última ficha: não pode. - Tina a pessoa
- O balão dela diz quem falou primeiro: ! ela interrompeu, ? perguntaram a ela, YES! ela aprovou.
- A tela a aprovação
- DROP? YES NO: a garra fica parada enquanto o hook espera. Nada roda até ela decidir.
No painel EventBus, a correção de rumo aparece como um evento user_steering, seguido do resultado da
chamada cancelada. Aprovações e respostas não têm evento próprio: são um hook e uma ferramenta que levaram o seu
tempo.
O código
Com astorlm: Um hook de aprovação para as ferramentas irreversíveis, embrulhado pelo
createSteeringController, que adiciona a fila de correções: chame steer(text) a partir da
sua interface e ela chega na próxima chamada de ferramenta. Escalar é uma ferramenta comum que espera pela sua
interface.
Do zero: Três checagens na etapa de ferramentas do loop do nível 2: uma correção na fila, uma aprovação para as ferramentas arriscadas e uma ferramenta que espera por uma pessoa.
import { OpenAIProvider, createLocalAgent, createSteeringController, 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
// Your UI: each of these resolves when the person clicks or types.
declare function approve(what: string): Promise<boolean> // shows YES / NO
declare function answer(question: string): Promise<string> // shows a text box
// 1. ESCALATE: a tool the model calls when it isn't sure. It waits for a person.
const askKid = tool({
name: 'ask_kid',
description: 'Ask Tina when you are not sure what she wants. Waits for her answer.',
schema: z.object({ question: z.string() }),
execute: async ({ question }) => answer(question),
})
// 2. APPROVE: irreversible tools wait for a yes before they run.
const NEEDS_APPROVAL = new Set(['drop_claw'])
const steering = createSteeringController({
beforeToolExecution: async ({ toolName, input }) => {
if (!NEEDS_APPROVAL.has(toolName)) return { authorize: true }
const ok = await approve(`${toolName}(${JSON.stringify(input)})`) // show the real call
return ok ? { authorize: true } : { authorize: false, mockResult: 'Tina said no. Ask her what to do.' }
},
})
const agent = await createLocalAgent({
provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
systemPrompt: 'You work a claw machine for Tina. If you are not sure which prize she means, ask her before acting.',
tools: [moveClaw, dropClaw, askKid], // moveClaw, dropClaw: your code
hooks: steering.hooks, // the steering controller wraps the approval hook
maxTurns: 20,
})
// 3. STEER: the person can correct the agent at any moment, e.g. from a button.
// The loop cancels the next tool call and hands the model this feedback instead.
onTinaShouts((text) => steering.steer(text)) // your UI: 'No, wait! The penguin!'
const result = await agent.run('Get me the bear!')
console.log(result.content)
// Human in the 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
}
// Your UI: each of these resolves when the person clicks or types.
declare function approve(what: string): Promise<boolean>
declare function answer(question: string): Promise<string>
type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = {
move_claw: moveClaw, // your code
drop_claw: dropClaw, // your code
// 1. ESCALATE: the model asks, a person answers.
ask_kid: ({ question }) => answer(String(question)),
}
const toolSchemas = [/* one JSON Schema per tool: move_claw(to), drop_claw(), ask_kid(question) */]
const NEEDS_APPROVAL = new Set(['drop_claw'])
// 3. STEER: a person can queue a correction at any time, e.g. from a button.
let steer: string | null = null
export const queueCorrection = (text: string) => {
steer = text
}
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'system' | 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
export async function runAgent(prompt: string, maxTurns = 20): Promise<string> {
const messages: Message[] = [
{ role: 'system', content: 'You work a claw machine for Tina. If you are not sure which prize she means, ask her before acting.' },
{ 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, 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 name = call.function.name
const args = JSON.parse(call.function.arguments)
let output: string
if (steer !== null) {
// The tool boundary: a queued correction cancels this call, and the model reads it instead.
output = `Cancelled. The person says: ${steer}`
steer = null
} else if (NEEDS_APPROVAL.has(name) && !(await approve(`${name}(${call.function.arguments})`))) {
// 2. APPROVE: irreversible tools wait here for a yes.
output = 'Tina said no. Ask her what to do.'
} else {
output = tools[name] ? await tools[name](args) : `Unknown tool: ${name}`
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
console.log(await runAgent('Get me the bear!'))
# Human in the loop, from scratch. Standard library only, no SDK.
import json
import queue
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)
# In a terminal the person is input(); in an app, whatever your UI sends back.
def approve(what):
return input(f"Approve {what}? [y/N] ").strip().lower() == "y"
def ask_kid(question): # 1. ESCALATE: the model asks, a person answers.
return input(f"The agent asks: {question}\n> ")
TOOLS = {"move_claw": move_claw, "drop_claw": drop_claw, "ask_kid": ask_kid} # move_claw, drop_claw: your code
TOOL_SCHEMAS = [...] # one JSON Schema per tool: move_claw(to), drop_claw(), ask_kid(question)
NEEDS_APPROVAL = {"drop_claw"}
# 3. STEER: another thread (a UI, a chat) can queue a correction at any time.
CORRECTIONS = queue.Queue()
def run_agent(prompt, max_turns=20):
messages = [
{"role": "system", "content": "You work a claw machine for Tina. If you are not sure which prize she means, ask her before acting."},
{"role": "user", "content": prompt},
]
for _ in range(max_turns):
choice = post("/chat/completions", {"model": LLM["model"], "messages": messages, "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", []):
name = call["function"]["name"]
args = json.loads(call["function"]["arguments"])
if not CORRECTIONS.empty():
# The tool boundary: a queued correction cancels this call, and the model reads it instead.
output = f"Cancelled. The person says: {CORRECTIONS.get()}"
elif name in NEEDS_APPROVAL and not approve(f"{name}({args})"):
# 2. APPROVE: irreversible tools wait here for a yes.
output = "Tina said no. Ask her what to do."
else:
output = TOOLS[name](**args)
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
print(run_agent("Get me the bear!"))
O que observar
- Peça menos, e o pedido vale mais. Aprove só o que é irreversível ou caro. Uma pessoa que clica em YES vinte vezes por hora para de ler, e a aprovação vira formalidade.
- Mostre a chamada real. “Descer?” não basta. Mostre a ferramenta e os argumentos exatos, o valor, o destinatário, o prêmio, para a pessoa aprovar o que de fato vai rodar.
- Decida o que acontece quando ninguém responde. Uma pessoa pode ir embora. Defina um timeout e escolha o padrão seguro: geralmente negar, e dizer ao modelo por quê.
- As correções chegam na próxima chamada de ferramenta. A correção de rumo não consegue parar uma ferramenta no meio nem uma resposta no meio de uma palavra. Se o agente só está escrevendo texto, a correção espera. Para uma parada brusca, aborte a execução.
- Mantenha um registro. Registre quem aprovou o quê, quando e o que viu. Quando algo dá errado, “foi o agente” nunca é a história toda.