Human in the Loop¶
Note
The person stays outside the graph. An action that needs them declares extra function
parameters (runtime inputs). You stop the application with
run(halt_before=["human_step"]), collect those values yourself, then call run
again and pass them as inputs.
An agent can run on its own until it needs a person – a prompt, a clarification, an
approval. Burr does not prompt the user. It stops, hands control back to your code, and
waits for the next run.
The action that needs a person looks like any other action. The extra parameters are not read from state; they come from the caller:
@action(reads=["draft"], writes=["approved"])
def human_step(state: State, approved: bool) -> State:
return state.update(approved=approved)
approved is a runtime input. Bind long-lived objects (an API client, a DB handle) with
.bind(...). Pass per-turn values with inputs={...} on run / step /
iterate. See inputs.
halt_before and run¶
Application.run walks the graph until a halt condition hits, then returns
(action, result, state).
halt_before=["human_step"]stops whenhuman_stepis the next action. That action has not run.resultisNone. The returnedactionishuman_step.halt_after=["final_result"]stops after that action has run.resultis that action’s result dict. The returnedactionisfinal_result.
If both could apply, halt_before wins. run always executes at least one action
before it checks halt conditions, then inputs apply only to that first action. Later
actions in the same run that also need inputs are undefined – halt before those
instead, and pass their values on the next run.
You can pass action names or tags ("@tag:needs_human"). The same halt arguments work
on iterate / arun / aiterate. Details are in Applications.
A graph with one handoff¶
This is the shape run(halt_before=["human_step"]) is for. No LLM required:
from burr.core import ApplicationBuilder, State, action
@action(reads=[], writes=["task"])
def start(state: State, task: str) -> State:
return state.update(task=task)
@action(reads=["task"], writes=["draft"])
def agent_work(state: State) -> State:
return state.update(draft=f"draft for: {state['task']}")
@action(reads=["draft"], writes=["approved"])
def human_step(state: State, approved: bool) -> State:
return state.update(approved=approved)
@action(reads=["draft", "approved"], writes=["result"])
def finish(state: State) -> State:
return state.update(result=state["draft"] if state["approved"] else None)
app = (
ApplicationBuilder()
.with_actions(start, agent_work, human_step, finish)
.with_transitions(
("start", "agent_work"),
("agent_work", "human_step"),
("human_step", "finish"),
)
.with_entrypoint("start")
.build()
)
action, result, state = app.run(
halt_before=["human_step"],
inputs={"task": "write a thank-you note"},
)
# action.name == "human_step", result is None
# start and agent_work have run; human_step has not
# state["draft"] is ready for the person to look at
action, result, state = app.run(
halt_after=["finish"],
inputs={"approved": True},
)
# first action of this run is human_step, so it receives approved=True
# then finish runs; action.name == "finish"
The first run stops before human_step. The second run starts at
human_step, so inputs={"approved": True} is what that action receives.
app.get_next_action() tells you which action is queued if you want to inspect
without running.
A chat loop¶
When the human action is both the entrypoint and the place you return to, collect the
prompt in the outer loop and halt before that same action. The first step of each
run is the human action (it gets inputs); the application then continues until
that action is next again:
from burr.core import ApplicationBuilder, State, action
@action(reads=[], writes=["prompt", "chat_history"])
def human_input(state: State, prompt: str) -> State:
return state.update(prompt=prompt).append(
chat_history={"role": "user", "content": prompt}
)
@action(reads=["chat_history"], writes=["response", "chat_history"])
def ai_response(state: State) -> State:
reply = f"you said: {state['chat_history'][-1]['content']}"
return state.update(response=reply).append(
chat_history={"role": "assistant", "content": reply}
)
app = (
ApplicationBuilder()
.with_actions(human_input, ai_response)
.with_transitions(
("human_input", "ai_response"),
("ai_response", "human_input"),
)
.with_state(chat_history=[])
.with_entrypoint("human_input")
.build()
)
while True:
prompt = input("you: ")
if prompt == "exit":
break
action, result, state = app.run(
halt_before=["human_input"],
inputs={"prompt": prompt},
)
print("ai:", state["response"])
In this two-node cycle, halt_after=["ai_response"] stops at the same place – after
the model has spoken, before the next human turn. halt_before=["human_input"] is the
same stop, with result is None and action.name == "human_input". The getting
started chatbot uses halt_after=["ai_response"] for a single turn; a running CLI
example of the halt_before loop is
examples/conversational-rag/graph_db_example/application.py
(human_converse / user_question).
Several handoff points¶
Some applications need a person more than once, at different actions. Halt before every
action that needs inputs, halt after the terminal action, and use action.name to
decide what to collect next.
This is the email assistant
(examples/email-assistant).
process_input takes email_to_respond and response_instructions.
clarify_instructions takes clarification_inputs (a list[str]).
process_feedback takes feedback. The graph goes
process_input → determine_clarifications → (clarify_instructions?) → formulate_draft → process_feedback → … → final_result.
inputs = {
"email_to_respond": incoming,
"response_instructions": instructions,
}
while True:
action, result, state = app.run(
halt_before=["clarify_instructions", "process_feedback"],
halt_after=["final_result"],
inputs=inputs,
)
if action.name == "clarify_instructions":
# clarify_instructions has not run; questions are already in state
answers = [input(q + " ") for q in state["clarification_questions"]]
inputs = {"clarification_inputs": answers}
elif action.name == "process_feedback":
comment = input("feedback (empty accepts the draft): ")
inputs = {"feedback": comment}
elif action.name == "final_result":
break
Include halt_after=["final_result"]. Without it, an empty feedback sends the
graph through final_result and then leaves it with no next action, which run
treats as undefined (it logs a warning).
The FastAPI wiring for the same halt list is
examples/email-assistant/server.py
(_run_through) and the walkthrough in
examples/web-server.
Web servers and persistence¶
The loop does not have to live in one process. On a web server each request loads the
application, calls run once, and returns. The next request supplies the next
inputs.
examples/email-assistant does that with initialize_from(..., resume_at_next_action=True)
and a persister / tracker keyed by app_id. The person can come back seconds or days
later. See State Persistence.
app.get_next_action() is useful here: the handler can tell the client which form to
show without advancing the graph.
Command-line only¶
If the application only ever runs in a terminal, you can call input(...) inside an
action and skip the outer loop. That does not work for a web server, a persisted app, or
any caller that is not sitting on stdin. Prefer halt_before + inputs unless you
are writing a one-off CLI.
Nested applications¶
Halting inside a sub-application and surfacing that to the parent is more involved.
The wrapping action runs the child with halt_before, then either:
sets a flag such as
need_input=Trueand returns, with a parent transition back to itself, orcopies the child’s finished state up and continues.
The parent still collects the person’s values and passes them as inputs into the
wrapping action on the next run. The child is not visible to the parent’s
halt_before list.
See also¶
Applications –
run,iterate,halt_before/halt_afterinputs – runtime inputs vs
.bind(...)State Persistence – pause and resume across requests