ai: make ask_user non-blocking, answer via steering
Assisted-by: GLM-5.3 via pi
change wwmxvzsxqtnlumqwropwlurqlwwplnur
commit dbc0b35fd7cd535fbc119b1df2a1c1204353f535
author Alpha Chen <alpha@kejadlen.dev>
date
parent qvvpkror
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
index dc08ef67..61912019 100644
--- a/ai/pi/extensions/README.md
+++ b/ai/pi/extensions/README.md
@@ -29,18 +29,29 @@ dialogs, and this extension records `blocked` for their duration.
 
 # Ask User
 
-Gives the agent an `ask_user` tool: a question shows as a dialog mid-turn,
-and the answer returns as the tool result, so the agent keeps going instead
-of ending its turn and waiting for a chat reply.
+Gives the agent an `ask_user` tool that asks the user a question without
+blocking: the tool returns immediately with a pending id, the question
+opens as a dialog in parallel with the agent's turn, and the answer is
+delivered later as a user message tagged `[ask_user <id>]` that repeats the
+question, so the model correlates them by id.
 
 ## How it works
 
+`execute()` fires the dialog without awaiting it and returns at once with
+an id (four random characters). When the user answers, the extension calls
+`pi.sendUserMessage(text, { deliverAs: "steer" })`: while the agent is
+mid-run the message queues as steering and enters after the current
+assistant turn; if the agent already went idle, it starts a fresh turn.
+
 Built on the `ctx.ui` dialogs rather than a custom component, so it works
 in the TUI and over RPC. With `options`, the user picks from a list with
 an "Other…" escape hatch that opens a text input; without, the user types
-a free-form answer. Dismissing either dialog cancels the question and the
-tool reports that the user didn't answer. In headless modes (JSON, print)
-the tool returns an error telling the agent to ask in its reply instead.
+a free-form answer. Dismissing the dialog sends a message saying the
+question was dismissed, so the model knows to re-ask or proceed. Dialogs
+are serialized (a module-level promise chain) because a second `ctx.ui`
+dialog disposes the first without resolving it. In headless modes (JSON,
+print) the tool returns an error telling the agent to ask in its reply
+instead.
 
 `permission-gate.ts` allowlists `ask_user` — gating a question behind a
 confirmation dialog would just be a second question.
diff --git a/ai/pi/extensions/ask-user.ts b/ai/pi/extensions/ask-user.ts
index f3e6971d..ae9efce6 100644
--- a/ai/pi/extensions/ask-user.ts
+++ b/ai/pi/extensions/ask-user.ts
@@ -1,39 +1,81 @@
 /**
- * ask_user - let the agent ask the user a question mid-turn.
+ * ask_user - let the agent ask the user a question without blocking.
  *
- * The question appears as a dialog instead of ending the turn in chat, so
- * the answer comes back as a tool result and the agent continues where it
- * left off. With options, the user picks from a list (with an "Other…"
- * escape hatch to type instead); without, the user types a free-form reply.
+ * The tool returns immediately with a pending id, and the question opens as
+ * a dialog in parallel with the agent's turn, so the model can keep working
+ * while it waits. When the user answers, the answer is sent as a steering
+ * user message tagged with the id and the question — delivered after the
+ * current assistant turn, or as a fresh turn if the agent went idle first.
  *
  * Uses the built-in ctx.ui dialogs rather than a custom component, so it
  * also works over RPC (headless modes get an error result instead).
  */
 
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
 import { Text } from "@earendil-works/pi-tui";
 import { Type } from "typebox";
 
 interface AskUserDetails {
+  id: string;
   question: string;
   options: string[];
-  answer: string | null;
-  typed?: boolean;
 }
 
 const OTHER = "Other…";
 
+// One dialog at a time: a second ctx.ui dialog disposes the first without
+// resolving its promise, so concurrent questions would hang.
+let dialogChain: Promise<void> = Promise.resolve();
+
+async function showDialog(
+  ctx: ExtensionToolContext,
+  question: string,
+  options: string[],
+): Promise<string | null> {
+  if (options.length === 0) {
+    return (await ctx.ui.input(question, "Your answer…")) ?? null;
+  }
+  const choice = await ctx.ui.select(question, [...options, OTHER]);
+  if (choice === undefined) return null;
+  if (choice === OTHER) {
+    return (await ctx.ui.input(question, "Your answer…")) ?? null;
+  }
+  return choice;
+}
+
+function askLater(
+  pi: ExtensionAPI,
+  ctx: ExtensionToolContext,
+  details: AskUserDetails,
+): void {
+  dialogChain = dialogChain
+    .then(async () => {
+      const answer = await showDialog(ctx, details.question, details.options);
+      const text =
+        answer === null
+          ? `[ask_user ${details.id}] dismissed "${details.question}" without an answer — re-ask it, or proceed without it.`
+          : `[ask_user ${details.id}] answer to "${details.question}": ${answer}`;
+      // Steer queues the message until the current assistant turn ends;
+      // if the agent is already idle it starts a new turn instead.
+      pi.sendUserMessage(text, { deliverAs: "steer" });
+    })
+    .catch(() => {
+      // Keep the chain alive for later questions.
+    });
+}
+
 export default function askUser(pi: ExtensionAPI) {
   pi.registerTool({
     name: "ask_user",
     label: "Ask user",
     description:
-      "Ask the user a question and wait for their answer. Use whenever you " +
-      "need information or a decision from the user before continuing; the " +
-      "answer returns as the tool result and you keep going in the same " +
-      "turn. Pass options to offer a choice (the user can still type a " +
-      "different answer); omit them for a free-text answer. Prefer this " +
-      "over ending your turn with a question in plain text.",
+      "Ask the user a question and keep working. Returns immediately with a " +
+      "pending id; the user answers in a dialog and their answer arrives " +
+      "later as a user message tagged [ask_user <id>] that repeats the " +
+      "question — match it by id and act on it then. Continue other work in " +
+      "this turn while it's pending rather than ending the turn or re-asking. " +
+      "Pass options to offer a choice (the user can still type a different " +
+      "answer); omit them for a free-text answer.",
     parameters: Type.Object({
       question: Type.String({ description: "The question to ask" }),
       options: Type.Optional(
@@ -45,14 +87,7 @@ export default function askUser(pi: ExtensionAPI) {
     executionMode: "sequential",
     annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
 
-    async execute(_toolCallId, params, signal, _onUpdate, ctx) {
-      const details = (answer: string | null, typed = false): AskUserDetails => ({
-        question: params.question,
-        options: params.options ?? [],
-        answer,
-        typed,
-      });
-
+    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
       if (!ctx.hasUI) {
         return {
           content: [
@@ -62,51 +97,28 @@ export default function askUser(pi: ExtensionAPI) {
             },
           ],
           isError: true,
-          details: details(null),
-        };
-      }
-
-      // Free-form question: go straight to the input dialog.
-      if (!params.options || params.options.length === 0) {
-        const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
-        if (answer === undefined) {
-          return {
-            content: [{ type: "text", text: "User dismissed the question without answering." }],
-            details: details(null, true),
-          };
-        }
-        return {
-          content: [{ type: "text", text: `User answered: ${answer}` }],
-          details: details(answer, true),
+          details: { id: "", question: params.question, options: [] },
         };
       }
 
-      const labels = params.options.filter((o) => o !== OTHER);
-      const choice = await ctx.ui.select(params.question, [...labels, OTHER], { signal });
-      if (choice === undefined) {
-        return {
-          content: [{ type: "text", text: "User dismissed the question without answering." }],
-          details: details(null),
-        };
-      }
-
-      if (choice === OTHER) {
-        const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
-        if (answer === undefined) {
-          return {
-            content: [{ type: "text", text: "User dismissed the question without answering." }],
-            details: details(null, true),
-          };
-        }
-        return {
-          content: [{ type: "text", text: `User answered: ${answer}` }],
-          details: details(answer, true),
-        };
-      }
+      const details: AskUserDetails = {
+        id: Math.random().toString(36).slice(2, 6),
+        question: params.question,
+        options: (params.options ?? []).filter((o) => o !== OTHER),
+      };
+      askLater(pi, ctx, details);
 
       return {
-        content: [{ type: "text", text: `User selected: ${choice}` }],
-        details: details(choice),
+        content: [
+          {
+            type: "text",
+            text:
+              `Question ${details.id} asked: "${details.question}". The user's ` +
+              `answer arrives later as a user message tagged ` +
+              `[ask_user ${details.id}] — continue other work until then.`,
+          },
+        ],
+        details,
       };
     },
 
@@ -120,16 +132,16 @@ export default function askUser(pi: ExtensionAPI) {
     },
 
     renderResult(result, _options, theme) {
-      const details = result.details as AskUserDetails | undefined;
-      if (!details) {
+      if (result.isError) {
         const text = result.content[0];
         return new Text(text?.type === "text" ? text.text : "", 0, 0);
       }
-      if (details.answer === null) {
-        return new Text(theme.fg("warning", "Dismissed"), 0, 0);
-      }
-      const prefix = details.typed ? "✓ (typed) " : "✓ ";
-      return new Text(theme.fg("success", prefix) + theme.fg("accent", details.answer), 0, 0);
+      const details = result.details as AskUserDetails;
+      return new Text(
+        theme.fg("muted", `⏳ ${details.id} — answer arrives as a user message`),
+        0,
+        0,
+      );
     },
   });
 }