What it enables
- Symmetric counterpart to Stop. Stop is “user halts the agent”;
ask_useris “agent asks the user.” Both are implemented as cooperative pauses anchored to the start of each loop iteration. - Better outputs on ambiguous prompts. The L4 model can now name the ambiguity (“Use shadcn or radix?”) and resume with the user’s answer folded into the next
tool_result, instead of picking arbitrarily and producing the wrong artifact. - Same UX everywhere. Reuses the existing
ask_useragent (worker/worker/graph/agents/ask_user.py) — samelanggraph.types.interruptplumbing, same bus events (ask_user,tool_call,tool_result), same composer behavior. No new FE work was required for this feature.
What’s changed
worker/worker/runtime/file_tools.py: added anask_userschema entry (7th tool) withquestion: string(required) andschema: object(optional). AddedINTERRUPT_TOOLS = frozenset({"ask_user"})and a guard inexecute_tool()so any code that mistakenly tries to dispatchask_useragainst the workspace gets a clear error rather than a silent miss.worker/worker/graph/agents/ask_user.py: addedASK_USER_TOOL_LOOP_INSTRUCTIONS— a tool-loop-specific instructions block telling the model to call the tool (not emit the legacy{"ask_user": ...}JSON envelope) when it needs a clarification.worker/worker/graph/scenarios/runners.py:_dispatch_toolextended withask_user_state: dict[str, int]andmax_ask_user: int. When the model callsask_user, dispatch goes throughmaybe_interrupt(...)instead ofexecute_tool(...). The helper emits the lifecycle (tool_call,ask_user,tool_result) itself, so the runner does NOT re-emit. On budget exhaustion, the runner emits a synthetictool_call+ errortool_resultand tells the model “answer with available context or call finish.”_run_loopinitialisesask_user_state = {"count": 0}and readsmax_ask_user = level.metadata.get("max_ask_user_per_loop", 3)per level. Default is 3 (constant_TOOL_LOOP_DEFAULT_MAX_ASK_USER).- Atomicity: in the paid path, if a single Anthropic turn’s
tool_callsincludesask_useralongside other calls (e.g.[ask_user, write_file]), the runner dispatchesask_useronly and synthesises askipped: ask_user took precedencetool_resultwithis_error: Truefor each remainingtool_use— so Anthropic’s “everytool_usegets a pairedtool_result” invariant holds, and the model can retry the skipped calls next turn with the user’s answer in hand. - The L4 system prompt (paid + free + paid→free fallback) is augmented with
ASK_USER_TOOL_LOOP_INSTRUCTIONS.
Impact scope
- Additive. The L4 loop’s existing termination paths (finish, no_tool_calls, max_iterations, budget, cancelled) are unchanged.
ask_useradds a fifth way for a turn to end without finishing the loop: the model’sask_usercall yields control to the user and the loop continues on the next iteration with the answer in the message history. - Off-loop
ask_useris unchanged.PromptLevelRunner._call_with_ask_user(used by L1/L2/L3 prompt levels) still parses the legacy{"ask_user": ...}JSON envelope viaextract_ask_user. The two paths sharemaybe_interrupt, the bus events, and the LangGraph interrupt mechanics — only the way the model signals a clarification differs (envelope vs. tool call). - No DB / API contract changes. The pause/resume goes through the existing LangGraph checkpointer; the FE composer already handles
ask_userevents. Nothing new formother-aito expose. - Scenario YAMLs do not need changes. The new tool is registered in
TOOL_SCHEMAS, so every existing tool-loop level (today:l4_execute,l2_escalate) gains the capability automatically. Per-level overrides vialevel.metadata.max_ask_user_per_loop(e.g. set it to0in a security-sensitive tool loop where pausing is undesirable, or to5in a long-running one).
Tests
tests/test_tool_loop_runner.py::ToolLoopAskUserTests (4 new tests, all passing):
test_paid_loop_ask_user_pauses_and_resumes— paid native tool-use path. Patcheslanggraph.types.interruptto return"shadcn"; assertsinterruptwas called with the model’s question, the next turn carries atool_resultbody of{"ok": true, "answer": "shadcn"}, and the loop terminates on the follow-upfinish.test_free_loop_ask_user_via_textual_block— same flow on the free transport: model emits a```tool ```block withname=ask_user, parsed byparse_textual_tool_call, dispatched the same way, answer round-trips through the prompt string.test_ask_user_budget_enforced— fiveask_userturns scripted; default budget (3) honored; the 4th call’stool_resultcarriesok: false, error: "ask_user budget exhausted ...";interruptwas called exactly 3 times, never the 4th.test_paid_loop_ask_user_blocks_remaining_tool_calls_in_turn— single Anthropic turn batches[ask_user, write_file]; asserts the workspace file was NOT written, and thewrite_filetool_use got askipped: ask_user took precedencepairedtool_resultso Anthropic’s invariant holds.