Skip to main content

What it enables

  • Symmetric counterpart to Stop. Stop is “user halts the agent”; ask_user is “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_user agent (worker/worker/graph/agents/ask_user.py) — same langgraph.types.interrupt plumbing, 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 an ask_user schema entry (7th tool) with question: string (required) and schema: object (optional). Added INTERRUPT_TOOLS = frozenset({"ask_user"}) and a guard in execute_tool() so any code that mistakenly tries to dispatch ask_user against the workspace gets a clear error rather than a silent miss.
  • worker/worker/graph/agents/ask_user.py: added ASK_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_tool extended with ask_user_state: dict[str, int] and max_ask_user: int. When the model calls ask_user, dispatch goes through maybe_interrupt(...) instead of execute_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 synthetic tool_call + error tool_result and tells the model “answer with available context or call finish.”
    • _run_loop initialises ask_user_state = {"count": 0} and reads max_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_calls includes ask_user alongside other calls (e.g. [ask_user, write_file]), the runner dispatches ask_user only and synthesises a skipped: ask_user took precedence tool_result with is_error: True for each remaining tool_use — so Anthropic’s “every tool_use gets a paired tool_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_user adds a fifth way for a turn to end without finishing the loop: the model’s ask_user call yields control to the user and the loop continues on the next iteration with the answer in the message history.
  • Off-loop ask_user is unchanged. PromptLevelRunner._call_with_ask_user (used by L1/L2/L3 prompt levels) still parses the legacy {"ask_user": ...} JSON envelope via extract_ask_user. The two paths share maybe_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_user events. Nothing new for mother-ai to 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 via level.metadata.max_ask_user_per_loop (e.g. set it to 0 in a security-sensitive tool loop where pausing is undesirable, or to 5 in 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. Patches langgraph.types.interrupt to return "shadcn"; asserts interrupt was called with the model’s question, the next turn carries a tool_result body of {"ok": true, "answer": "shadcn"}, and the loop terminates on the follow-up finish.
  • test_free_loop_ask_user_via_textual_block — same flow on the free transport: model emits a ```tool ``` block with name=ask_user, parsed by parse_textual_tool_call, dispatched the same way, answer round-trips through the prompt string.
  • test_ask_user_budget_enforced — five ask_user turns scripted; default budget (3) honored; the 4th call’s tool_result carries ok: false, error: "ask_user budget exhausted ..."; interrupt was 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 the write_file tool_use got a skipped: ask_user took precedence paired tool_result so Anthropic’s invariant holds.
Run:
/Users/azat/labs/cerebrum/worker/.venv/bin/python -m pytest tests/test_tool_loop_runner.py -v
12 tests pass (4 new + 8 pre-existing). The 9 pre-existing failures elsewhere in the suite (provider_registry, security_review, scheduler_producer, context_engine) are unrelated and already failed on master prior to this change.