User input requests: pausing a run to ask the user#
This document describes how a running function pauses to ask the user a question and resumes with the answer: the API surface, the pending-question registry, the transport that carries a question out of a subprocess, and how the answer travels back. Companion: ../cli/tui-upgrade.md (TUI surface).
Problem#
A function (especially an @agentic_function) sometimes needs the user
mid-run: confirm a destructive step, pick between alternatives, supply a
missing value. That requires pausing execution, surfacing the question in
web/TUI/channels, and resuming with the answer.
Pre-existing mechanisms#
Three related mechanisms exist in the codebase, none of them alive end-to-end on the main chat path:
| Mechanism | State |
|---|---|
ask_user / set_ask_user / FollowUp (openprogram/functions/agentics/ask_user/) |
Primitive complete, DAG awaiting-node bookkeeping complete; but no handler registered in the worker, and the agentic subprocess bridge is one-way — returns None in practice |
webui follow-up round-trip (webui/server.py:234-270, WS follow_up_answer action, web handleFollowUpQuestion) |
All three segments exist; the initiator _web_follow_up has no caller left — dead code. The web UI side is legacy DOM injection into #runtime_pending (only exists while a runtime block streams). TUI types the envelope but never handles it |
Approval gate (openprogram/agent/internals/_approval.py, wired in dispatcher) |
Wait machinery complete and live, but resolve() is only called from tests; no web/TUI UI; default bypass masks it; sub-agents force bypass to avoid 300s hangs |
The skeleton — blocking queue, WS action, stop-sentinel unblocking, DAG awaiting nodes — is therefore already available. What this design adds is the registry shape (per-request, not a global handler slot), the subprocess answer channel, real frontend UI, and explicit timeout semantics.
The binding constraint is that @agentic_function bodies run in a spawned
subprocess (agent/process_runner.py) whose mp.Queue is child→parent only.
Any design must add a parent→child answer queue; no amount of worker-side
wiring avoids this.
Reference designs (what we take)#
- opencode: tool calls
ctx.ask(...)→ server-side Deferred + pending map → eventpermission.askeddown, REST reply up, plus a list endpoint so a reconnecting client can recover pending questions. Reject may carry a message that becomes the tool-error text the model sees. - Claude Code: AskUserQuestion rides the permission pipeline; options + always-present "Other" free-text; pending request snapshot persisted in session metadata so remote UIs can redraw it (execution stack never persisted); tools that require interaction are disabled when no human is attached.
- openclaw: 30-min timeout with an explicit fallback (never silent);
channel buttons whose value is a plain text command (
/approve <id> …) so text-only channels work identically; for channel-initiated runs, the tool returns "pending" immediately and the result is re-injected later (non-blocking mode). - MCP elicitation: the three-outcome protocol — accept / decline / cancel — and flat-object schema constraints for form-style asks.
All four implement "execution point blocks on a primitive, UI resolves it" — no generator/coroutine acrobatics. Ours blocks a thread (functions already run in threads/subprocesses).
API#
On runtime, next to runtime.exec / decision:
# Inside any @agentic_function / @function body
answer = runtime.ask(
"Which library for date formatting?",
options=["dayjs", "date-fns", "luxon"], # optional; None = free text
multi=False, # True -> returns list[str]
allow_custom=True, # free text allowed besides options
timeout=1800, # seconds, default 30 min
default=None, # returned on timeout; no default -> AskTimeout
)
# -> str (or list[str]); user pressing Decline raises UserDeclined
ok = runtime.confirm("Archive all 87 emails?", detail=preview,
timeout=600, default=False) # -> bool, never raises on timeout
runtime.can_ask() # -> bool; False in headless runs so authors can branch
ask_user(question)is a thin alias ofruntime.ask(question). With no global handler installed it falls back toruntime.ask, and UserDeclined/AskTimeout collapse toNoneto preserve the older semantics; the CLI'sset_ask_userpath is unaffected.- The
clarifybuilt-in tool (LLM-callable) works through the same path. - Three explicit outcomes: answered / declined / timeout. There is no silent
Nonereturn after 300 s. runtime.form(...)(MCP-elicitation-style flat schema) is deferred.
Mechanism#
- Registry (worker process):
PendingQuestion {id, session_id, kind, prompt, options, multi, allow_custom, created_at, expires_at}+ a per-requestthreading.Event. This replaces the globalset_ask_userhandler slot, which two concurrent sessions could overwrite for each other. Resolve is an atomic claim-once;handle_stopputs the cancel sentinel exactly like the existing follow-up queues. - Protocol: WS broadcast
question.asked / question.replied / question.rejected; RESTGET /api/questions?session_id=+POST /api/questions/{id}/reply/.../rejectfor reconnect recovery (webui/routes/questions.py).handle_load_sessionreplays still-pendingquestion.askedframes on (re)connect. This reuses the existing_broadcast_chat_responseplumbing, whose post-stop silence is the behavior we want. - Subprocess bridge: which channel a question travels on is a
QuestionTransport, shaped like a Python logging Handler (publishcorresponds toHandler.emit):EventLayerTransport(default — event layer to frontend card and bus, used by the worker) andQueueTransport(back to the parent process over mp.Queue, used by the subprocess). The runtime holds its transport explicitly (runtime._question_transport) rather than through a module-level global switch.run_agentic_in_subprocessadds a parent→childanswer_queue;_child_entryinstallsQueueTransporton the subprocess runtime (questions travel up overevent_queuetagged__op_question__) and starts an answer-pump thread that takes answers offanswer_queueand resolves the subprocess-local registry. In the parent,_drainintercepts that envelope, and_bridge_question_to_parentregisters the same qid in the parent registry, emits the frontend card, and starts a waiter; a WS reply resolves the parent registry through the existing_resolve_question, and the waiter pushes the answer back ontoanswer_queue. When the subprocess exits or is stopped, the parent closes out any remaining pending question as declined and retracts the card (claim-once, so a duplicate resolve is harmless). - Persistence: persist the request snapshot, not the execution stack.
The DAG already writes
status="awaiting"user-role nodes; on worker restart, leftover pendings are marked expired and DAG nodesunanswered. No durable-execution resume (all four references deliberately skip it). - Frontends: web gets a React question card in the message stream
(replacing the legacy DOM injection) and the composer doubles as the
answer box while a question is pending; TUI renders the question in the
input slot (tui-upgrade.md P2). First answer wins across surfaces;
question.repliedretracts the UI elsewhere. - Approval merge:
_approval.pymoves onto the same registry askind="approval", giving the otherwise unreachableaskpermission mode a real UI, with opencode's reply shape (allow once / always / reject with feedback that becomes the tool error text). - Channels: buttons-as-text-commands (
/answer <id> <choice>); for channel-initiated runs prefer the non-blockingFollowUpshape (reply ends the turn, user's next message resumes the function) instead of holding a thread for 30 minutes.
Open questions#
- Timeout default: 30 min (openclaw) vs shorter for web-first usage.
- Whether
decision.makeshould eventually route through the same registry when the decision target is the human rather than the model (out of scope here, noted for the function-calling unification doc).
Appendix: Implementation Status#
Implemented: the registry, runtime.ask / confirm / can_ask, the three
explicit outcomes (answered / UserDeclined / AskTimeout), the WS
question_reply/reject protocol, the web question card, and stop releasing
pending questions via cancel_session — in agent/questions.py,
agentic_programming/runtime.py, webui/ws_actions/session.py,
webui/ws_actions/runtime.py, and web/components/ui/question-prompt.tsx.
Reconnect recovery is implemented. A question card is driven only by a live
question.asked frame, so after a refresh or a dropped connection that frame
is gone; handle_load_session replays every still-pending question of the
session as the same question.asked frame, so the frontend redraws with no
changes of its own. REST GET /api/questions + POST /api/questions/{id}/reply|reject (webui/routes/questions.py) give the same
registry an API-side equivalent, with reply/reject funneling through the same
_resolve_question as WS.
The subprocess bridge is implemented, so @agentic_function bodies can ask:
QuestionTransport (EventLayerTransport / QueueTransport) plus the
parent↔child bridge in process_runner (questions up over event_queue,
answers back over answer_queue) — in agent/questions.py (the three
transport classes + emit_question_asked), agentic_programming/runtime.py
(set_question_transport, _ask_raw going through
self._question_transport), and agent/process_runner.py (answer_queue,
answer-pump, _bridge_question_to_parent, _decline_bridged_question).
Covered by tests/agent/test_questions_subprocess_bridge.py (8 unit tests)
plus a real spawned-subprocess end-to-end check.
Not yet landed: the TUI surface (question/approval prompt in the input slot,
tracked in tui-upgrade.md), and the approval merge, channels, and
runtime.form described above.