ai: make ask_user non-blocking, answer via steering
Assisted-by: GLM-5.3 via pi
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,
+ );
},
});
}