Nivel 13
Humano en el bucle
- user
- assistant
- tool_result
- tool_result (error)
EventBus
El problema
Un agente que corre sin una persona es rápido justo hasta el momento en que hace algo que nadie quería: paga la factura equivocada, le manda un email a todos los clientes, gasta la última moneda en el premio equivocado. Y un agente que se detiene a preguntar por todo no es más rápido que hacerlo tú mismo.
Ese es Autopilot Rex: nunca pregunta, nunca espera, nunca atiende un llamado. Se aferra a su primer plan, ignora lo que cambió desde entonces y adivina cuando debería haber preguntado.
La solución
Pon a una persona en los pocos puntos donde su criterio importa, y deja que el agente corra en todos los demás. Hay tres de esos puntos, y se diferencian en quién rompe el silencio:
-
Aprobar
El agente espera
Antes de que se ejecute una herramienta irreversible, un hook le muestra a la persona exactamente qué va a hacer y espera un sí o un no.
Pagos, borrados, mensajes a clientes, la última moneda: cualquier cosa que no se pueda deshacer.
-
Corregir el rumbo
La persona interrumpe
La persona deja una corrección en cola cuando quiere. En la siguiente llamada a herramienta, el bucle cancela esa llamada y le da al modelo la corrección en su lugar.
Alguien está mirando y cambia de idea, o ve que el agente va en la dirección equivocada.
-
Escalar
El agente pregunta
Una herramienta como ask_human que no devuelve nada hasta que una persona responde. El modelo decide cuándo usarla.
Falta información, hay dos opciones igual de buenas, poca confianza. El system prompt dice cuándo preguntar.
Aprobar y corregir el rumbo viven en el hook beforeToolExecution del nivel 6: corre antes de cada
herramienta, y puede esperar. Para una aprobación espera la respuesta de la persona; para corregir el rumbo revisa
si hay una corrección en cola. En ambos casos, lo que dijo la persona vuelve al modelo como el resultado de la
herramienta, así la ejecución sigue con la información nueva en lugar de romperse.
El elenco
El mismo elenco de siempre, esta vez en el arcade.
- La adivina el modelo
- El Oráculo, en su cabina. Lee el historial y decide la siguiente llamada. Nunca toca la máquina.
- Los controles las herramientas
-
move_clawes gratis y se puede deshacer.drop_clawgasta la última moneda: no se puede. - Tina la persona
- Su globo dice quién habló primero: ! ella interrumpió, ? le preguntaron, YES! aprobó.
- La pantalla la aprobación
- DROP? YES NO: la garra queda quieta mientras el hook espera. Nada se ejecuta hasta que ella decide.
En el panel EventBus, la corrección de rumbo aparece como un evento user_steering, seguido del
resultado de la llamada cancelada. Las aprobaciones y las respuestas no tienen un evento propio: son un hook y una
herramienta que se tomaron su tiempo.
El código
Con astorlm: Un hook de aprobación para las herramientas irreversibles, envuelto por
createSteeringController, que agrega la cola de correcciones: llama a steer(text) desde
tu interfaz y llega en la siguiente llamada a herramienta. Escalar es una herramienta común que espera a tu
interfaz.
Desde cero: Tres verificaciones en el paso de herramientas del bucle del nivel 2: una corrección en cola, una aprobación para las herramientas riesgosas y una herramienta que espera a una persona.
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!"))
Qué vigilar
- Pide menos, y vale más. Aprueba solo lo irreversible o lo caro. Una persona que hace clic en YES veinte veces por hora deja de leer, y la aprobación se vuelve un trámite.
- Muestra la llamada real. “¿Bajar?” no alcanza. Muestra la herramienta y sus argumentos exactos, el monto, el destinatario, el premio, para que la persona apruebe lo que de verdad se va a ejecutar.
- Decide qué pasa cuando nadie responde. Una persona puede irse. Pon un timeout, y elige el valor por defecto seguro: normalmente denegar, y decirle al modelo por qué.
- Las correcciones llegan en la siguiente llamada a herramienta. La corrección de rumbo no puede detener una herramienta a mitad de ejecución ni una respuesta a mitad de palabra. Si el agente solo está escribiendo texto, la corrección espera. Para una parada en seco, aborta la ejecución.
- Guarda un registro. Registra quién aprobó qué, cuándo y qué vio. Cuando algo sale mal, “lo hizo el agente” nunca es toda la historia.