Level 13
Human in the loop
- user
- assistant
- tool_result
- tool_result (error)
EventBus
The problem
An agent that runs without a person is fast right up to the moment it does something nobody wanted: pays the wrong invoice, emails every customer, spends the last coin on the wrong prize. And an agent that stops to ask about everything is no faster than doing it yourself.
That’s Autopilot Rex: never asks, never waits, never takes a call. It commits to its first plan, ignores what changed since, and guesses when it should have asked.
The solution
Put a person at the few points where their judgment matters, and let the agent run everywhere else. There are three such points, and they differ in who breaks the silence:
-
Approve
The agent waits
Before an irreversible tool runs, a hook shows the person exactly what it will do and waits for a yes or a no.
Payments, deletions, messages to customers, the last coin: anything you can’t take back.
-
Steer
The person interrupts
The person queues a correction whenever they like. At the next tool call the loop cancels that call and gives the model the correction instead.
Someone is watching and changes their mind, or sees the agent heading the wrong way.
-
Escalate
The agent asks
A tool like ask_human that doesn’t return until a person answers. The model decides when to use it.
Missing information, two equally good options, low confidence. The system prompt says when to ask.
Approving and steering both live in the beforeToolExecution hook from level 6: it runs before every
tool, and it can wait. For an approval it waits for the person’s answer; for steering it checks whether a
correction is queued. Either way, what the person said goes back to the model as the tool’s result, so the run
goes on with the new information instead of crashing.
The cast
Same cast as always, at the arcade this time.
- The fortune teller the model
- The Oracle, in its booth. It reads the history and decides the next call. It never touches the machine.
- The controls the tools
-
move_clawis free and can be undone.drop_clawspends the last coin: it can’t. - Tina the person
- Her bubble says who spoke first: ! she interrupted, ? she was asked, YES! she approved.
- The screen the approval
- DROP? YES NO: the claw hangs still while the hook waits. Nothing runs until she decides.
In the EventBus panel, steering shows up as a user_steering event, followed by the cancelled call’s
result. Approvals and answers have no event of their own: they’re a hook and a tool that took their time.
The code
With astorlm: An approval hook for the irreversible tools, wrapped by
createSteeringController, which adds the steering queue: call steer(text) from your UI
and it lands at the next tool call. Escalation is an ordinary tool that awaits your UI.
From scratch: Three checks in the tool step of the loop from level 2: a queued correction, an approval for the risky tools, and a tool that waits for a person.
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!"))
What to watch
- Ask for less, and it means more. Approve only what’s irreversible or expensive. A person who clicks YES twenty times an hour stops reading, and the approval becomes a formality.
- Show the real call. “Drop?” isn’t enough. Show the tool and its exact arguments, the amount, the recipient, the prize, so the person approves what will actually run.
- Decide what happens when nobody answers. A person can walk away. Set a timeout, and pick the safe default: usually deny, and tell the model why.
- Corrections arrive at the next tool call. Steering can’t stop a tool mid-run or a reply mid-word. If the agent is only writing text, the correction waits. For a hard stop, abort the run.
- Keep a record. Log who approved what, when, and what they saw. When something goes wrong, “the agent did it” is never the whole story.