Skip to content

Commit 2339789

Browse files
committed
Implemented asking questions during the planning mode or conversations
1 parent c6185df commit 2339789

28 files changed

Lines changed: 2478 additions & 25 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"iconv-lite": "0.6.3",
2020
"ignore": "7.0.5",
2121
"tree-kill": "1.2.2",
22+
"typebox": "1.1.38",
2223
"vscode-jsonrpc": "9.0.0",
2324
"vscode-languageserver-protocol": "3.18.0",
2425
"ws": "~8.20.1",

packages/core/src/extensions/agent-mode/decision.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export const DEFAULT_READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
2525
"ls",
2626
"glob",
2727
"tree",
28+
29+
// A shortcut to enable interactions between an agent and a developer. This flow would
30+
// allow to ask questions and then get a response from the user. The response is used
31+
// by the agent to make a decision
32+
"ask_user_question",
2833
]);
2934

3035
/** Plan mode defaults to prompting (rather than blocking) for non-read-only operations. */

packages/core/src/extensions/agent-mode/plan-prompt.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ function planSection(planFilePath: string): string {
3131
"except for the plan file itself (see below). Other writes will be blocked by the host.",
3232
"",
3333
"Use read-only tools (read, grep, glob, web fetch) to research about the problem and prepare",
34-
"code before you commit to anything. If the request is ambiguous, ask focused clarifying",
35-
"questions as your final message and stop — the user answers in their next turn.",
34+
"code before you commit to anything. When a decision is genuinely open or ambiguous and",
35+
"when its possible to offer concrete options (e.g. with code / diff previews) — ask a user",
36+
"instead of writing the options into the plan or listing them as prose. It renders an interactive ",
37+
"picker and pauses for the answer, which then steers the plan.",
3638
"",
3739
"When the request is clear enough, you MUST save the plan to the file with the write",
3840
"tool. Any further updates also should be applied and reflected in the plan file. Write " +
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { randomUUID } from "node:crypto";
2+
import {
3+
defineTool,
4+
type ExtensionAPI,
5+
type ExtensionFactory,
6+
} from "@earendil-works/pi-coding-agent";
7+
import type { AskUserAnswer } from "../../protocol/commands.js";
8+
import type { AskUserQuestion } from "../../protocol/events.js";
9+
import type { AskFrontend } from "./frontend.js";
10+
import { AskUserToolParams } from "./schema.js";
11+
12+
/** The tool id the model calls. */
13+
export const ASK_USER_TOOL_NAME = "ask_user_question";
14+
15+
const TOOL_DESCRIPTION =
16+
"Ask the user one to four structured multiple-choice questions and wait for their answer. " +
17+
"Reach for this the moment a decision is ambiguous and costly to reverse, or to let the user " +
18+
"steer a plan - instead of guessing or listing the options as prose. " +
19+
"IMPORTANT: when you ask, CALL THIS TOOL as your action; do NOT also write the question or its " +
20+
"options as assistant text - the card renders them. The UI always offers the user a free-text " +
21+
'"Something else" answer, so you never need to add your own "other" option. ' +
22+
"Set `multiSelect` for 'choose any', and give an option a `preview` (markdown, e.g. a fenced " +
23+
"code/diff block) to show what it would change. The tool returns the user's selections as text.";
24+
25+
const TOOL_PROMPT_SNIPPET =
26+
"ask_user_question — ask the user 1–4 multiple-choice questions and wait for their pick; use " +
27+
"when a decision is ambiguous or to let them steer a plan.";
28+
29+
const TOOL_GUIDELINES = [
30+
"When a choice is ambiguous and hard to undo, call ask_user_question instead of guessing.",
31+
"Call the tool directly - do not first write the question or options as plain text; the card " +
32+
"renders them. Narrating them as prose and then calling the tool duplicates everything.",
33+
"Keep each question focused with 2–4 distinct options and a one-line description each.",
34+
"Use one question for a single decision, or several to gather related choices at once " +
35+
"(typical while planning).",
36+
"A free-text answer is always available to the user - don't add your own 'other' / " +
37+
"'something else' option.",
38+
"Don't use it for trivial or easily reversible choices - just proceed.",
39+
];
40+
41+
export interface AskUserExtensionOptions {
42+
/** Where questions are presented and answers collected. pi-deck injects a GUI frontend. */
43+
frontend: AskFrontend;
44+
}
45+
46+
export interface AskUserController {
47+
/** Pass to `DefaultResourceLoader({ extensionFactories: [...] })`. */
48+
readonly factory: ExtensionFactory;
49+
/** Cancel any in-flight questions (delegates to the frontend). */
50+
dispose(): void;
51+
}
52+
53+
/**
54+
* pi-deck's self-contained "ask the user a question" plugin. It registers the
55+
* `ask_user_question` tool; its `execute()` suspends on the injected {@link AskFrontend} and
56+
* returns the user's answer as a clean tool result (no terminal dependency, no block-hack).
57+
*
58+
* The plugin owns no IO and no network: just the tool and a frontend port, so it can later be
59+
* lifted into a standalone package.
60+
*/
61+
export function createAskUserExtension(options: AskUserExtensionOptions): AskUserController {
62+
const { frontend } = options;
63+
64+
const factory: ExtensionFactory = (pi: ExtensionAPI) => {
65+
pi.registerTool(
66+
defineTool({
67+
name: ASK_USER_TOOL_NAME,
68+
label: "Ask the user",
69+
description: TOOL_DESCRIPTION,
70+
promptSnippet: TOOL_PROMPT_SNIPPET,
71+
promptGuidelines: TOOL_GUIDELINES,
72+
parameters: AskUserToolParams,
73+
async execute(toolCallId, params, signal) {
74+
const questions = params.questions as AskUserQuestion[];
75+
const answer = await frontend.present(
76+
{ askId: randomUUID(), toolCallId, questions },
77+
signal,
78+
);
79+
return {
80+
content: [{ type: "text" as const, text: formatAnswers(questions, answer) }],
81+
details: undefined,
82+
};
83+
},
84+
}),
85+
);
86+
};
87+
88+
return {
89+
factory,
90+
dispose() {
91+
frontend.dispose?.();
92+
},
93+
};
94+
}
95+
96+
/**
97+
* Render the user's answer as a compact, model-friendly transcript that becomes the tool
98+
* result. Index-aligned to `questions`, tolerant of a short/partial answers array.
99+
*/
100+
export function formatAnswers(questions: AskUserQuestion[], answer: AskUserAnswer): string {
101+
if (answer.cancelled) {
102+
return (
103+
"The user dismissed the question without answering. Proceed using your best judgment, " +
104+
"or ask again if you still need a decision."
105+
);
106+
}
107+
const blocks = questions.map((q, i) => {
108+
const n = i + 1;
109+
const a = answer.answers[i];
110+
let ans: string;
111+
if (!a || a.skipped) {
112+
ans = "(skipped)";
113+
} else {
114+
const picks: string[] = [];
115+
for (const idx of a.optionIndices) {
116+
const opt = q.options[idx];
117+
if (opt) picks.push(opt.label);
118+
}
119+
120+
if (a.custom?.trim()) picks.push(a.custom.trim());
121+
ans = picks.length > 0 ? picks.join(", ") : "(no selection)";
122+
}
123+
return `Q${n}: ${q.question}\nA${n}: ${ans}`;
124+
});
125+
return blocks.join("\n\n");
126+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { AskUserAnswer } from "../../protocol/commands.js";
2+
import type { AskUserQuestion } from "../../protocol/events.js";
3+
4+
/** Default wait before an unanswered question auto-cancels. Questions (especially plan-steering
5+
* ones) can take a while to think through, so this is far longer than the approval timeout. */
6+
export const ASK_USER_TIMEOUT_MS = 30 * 60_000;
7+
8+
/** A pending question handed to whatever frontend is wired in. */
9+
export interface AskRequest {
10+
askId: string;
11+
toolCallId: string;
12+
questions: AskUserQuestion[];
13+
}
14+
15+
/**
16+
* Frontend-agnostic port the ask-user tool talks to. The tool doesn't know or care whether a
17+
* GUI or a TUI is on the other end - it just `present()`s a request and awaits an answer.
18+
* pi-deck injects a GUI frontend (see `createDeferredFrontend`); a terminal presenter is a
19+
* clean future drop-in against the same interface.
20+
*/
21+
export interface AskFrontend {
22+
present(request: AskRequest, signal?: AbortSignal): Promise<AskUserAnswer>;
23+
/** Optional: cancel any in-flight questions when the session shuts down. */
24+
dispose?(): void;
25+
}
26+
27+
export interface DeferredFrontendOptions {
28+
/** Called synchronously when a question needs to reach the user (e.g. emit a host event). */
29+
onAskRequest: (request: AskRequest) => void;
30+
/** Auto-cancel timeout. Defaults to {@link ASK_USER_TIMEOUT_MS}. */
31+
timeoutMs?: number;
32+
/** Injectable timers for deterministic tests. */
33+
timers?: {
34+
setTimeout: (cb: () => void, ms: number) => unknown;
35+
clearTimeout: (handle: unknown) => void;
36+
};
37+
}
38+
39+
export interface DeferredFrontend extends AskFrontend {
40+
/** Resolve a pending question with the user's answer. Unknown ids are a no-op so a stale or
41+
* duplicate reply from the renderer doesn't throw. */
42+
resolveAsk(askId: string, answer: AskUserAnswer): void;
43+
/** Snapshot of currently pending question ids. */
44+
pendingAskIds(): string[];
45+
/** Cancel every pending question; used when the worker shuts down mid-turn. */
46+
dispose(): void;
47+
}
48+
49+
const CANCELLED: AskUserAnswer = { answers: [], cancelled: true };
50+
51+
interface PendingEntry {
52+
resolve: (answer: AskUserAnswer) => void;
53+
cleanup: () => void;
54+
}
55+
56+
/**
57+
* The host-driven realization of {@link AskFrontend}: `present()` registers a pending entry,
58+
* fires `onAskRequest`, and suspends until `resolveAsk()` is called from the host (with the
59+
* renderer's answer), the timeout elapses, or the turn's `AbortSignal` fires. This is exactly
60+
* what the GUI flow needs - it mirrors the agent-mode approval suspend/resume machinery.
61+
*/
62+
export function createDeferredFrontend(options: DeferredFrontendOptions): DeferredFrontend {
63+
const timers = options.timers ?? {
64+
setTimeout: (cb, ms) => globalThis.setTimeout(cb, ms),
65+
clearTimeout: (h) => globalThis.clearTimeout(h as ReturnType<typeof globalThis.setTimeout>),
66+
};
67+
const timeoutMs = options.timeoutMs ?? ASK_USER_TIMEOUT_MS;
68+
const pending = new Map<string, PendingEntry>();
69+
70+
function finish(askId: string, answer: AskUserAnswer): void {
71+
const entry = pending.get(askId);
72+
if (!entry) return;
73+
pending.delete(askId);
74+
entry.cleanup();
75+
entry.resolve(answer);
76+
}
77+
78+
return {
79+
present(request, signal) {
80+
return new Promise<AskUserAnswer>((resolve) => {
81+
if (signal?.aborted) {
82+
resolve(CANCELLED);
83+
return;
84+
}
85+
const timerHandle = timers.setTimeout(() => finish(request.askId, CANCELLED), timeoutMs);
86+
const onAbort = () => finish(request.askId, CANCELLED);
87+
signal?.addEventListener("abort", onAbort, { once: true });
88+
pending.set(request.askId, {
89+
resolve,
90+
cleanup: () => {
91+
timers.clearTimeout(timerHandle);
92+
signal?.removeEventListener("abort", onAbort);
93+
},
94+
});
95+
options.onAskRequest(request);
96+
});
97+
},
98+
resolveAsk(askId, answer) {
99+
finish(askId, answer);
100+
},
101+
pendingAskIds() {
102+
return [...pending.keys()];
103+
},
104+
dispose() {
105+
for (const askId of [...pending.keys()]) finish(askId, CANCELLED);
106+
},
107+
};
108+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export {
2+
ASK_USER_TOOL_NAME,
3+
type AskUserController,
4+
type AskUserExtensionOptions,
5+
createAskUserExtension,
6+
formatAnswers,
7+
} from "./ask-user.js";
8+
export {
9+
ASK_USER_TIMEOUT_MS,
10+
type AskFrontend,
11+
type AskRequest,
12+
createDeferredFrontend,
13+
type DeferredFrontend,
14+
type DeferredFrontendOptions,
15+
} from "./frontend.js";
16+
export { type AskUserToolInput, AskUserToolParams } from "./schema.js";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type Static, Type } from "typebox";
2+
3+
/**
4+
* TypeBox parameter schema for the `ask_user_question` tool - this is what the model fills in.
5+
* It is deliberately our *own*, presentation-agnostic shape (content + semantics only, no
6+
* layout/pixel fields) so the same payload can be rendered by the pi-deck GUI today or a TUI.
7+
*
8+
* The wire/runtime question shape is defined once as Zod in `protocol/events.ts`
9+
* (`AskUserQuestionSchema`); a runtime test keeps the two in lock-step.
10+
*/
11+
const AskUserOptionParams = Type.Object(
12+
{
13+
id: Type.Optional(Type.String({ description: "Optional stable id for the option." })),
14+
label: Type.String({ description: "Short option label shown to the user." }),
15+
description: Type.Optional(
16+
Type.String({ description: "One-line explanation of what this option means or does." }),
17+
),
18+
preview: Type.Optional(
19+
Type.String({
20+
description:
21+
"Optional preview of the concrete change this option makes - ONLY a fenced code or " +
22+
"diff block (```lang ... ```). It opens a side-by-side code pane. Do NOT put prose " +
23+
"trade-offs here (those go in `description`); a non-code preview is ignored.",
24+
}),
25+
),
26+
},
27+
{ additionalProperties: false },
28+
);
29+
30+
const AskUserQuestionParams = Type.Object(
31+
{
32+
id: Type.Optional(Type.String({ description: "Optional stable id for the question." })),
33+
header: Type.String({ description: "Very short section/tab label, e.g. 'Theme strategy'." }),
34+
question: Type.String({ description: "The question to put to the user." }),
35+
options: Type.Array(AskUserOptionParams, {
36+
minItems: 2,
37+
maxItems: 4,
38+
description: "Two to four options the user can pick from.",
39+
}),
40+
multiSelect: Type.Optional(
41+
Type.Boolean({ description: "Allow selecting several options (checkbox list)." }),
42+
),
43+
allowCustom: Type.Optional(
44+
Type.Boolean({ description: "Offer a free-text 'Something else' answer." }),
45+
),
46+
minSelect: Type.Optional(
47+
Type.Number({ description: "Minimum selections for a multiSelect question." }),
48+
),
49+
maxSelect: Type.Optional(
50+
Type.Number({ description: "Maximum selections for a multiSelect question." }),
51+
),
52+
},
53+
{ additionalProperties: false },
54+
);
55+
56+
export const AskUserToolParams = Type.Object(
57+
{
58+
questions: Type.Array(AskUserQuestionParams, {
59+
minItems: 1,
60+
maxItems: 4,
61+
description:
62+
"One to four questions to put to the user. Use a single question for a focused " +
63+
"decision; use several to gather a few related choices at once (e.g. while planning).",
64+
}),
65+
},
66+
{ additionalProperties: false },
67+
);
68+
69+
export type AskUserToolInput = Static<typeof AskUserToolParams>;

packages/core/src/host/router.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,11 @@ const handlers: { [C in CommandName]: CommandHandler } = {
437437
);
438438
return { ok: true as const };
439439
},
440+
"session.answerQuestion": async (ctx, payload) => {
441+
const parsed = CommandSchemas["session.answerQuestion"].request.parse(payload);
442+
await ctx.sessionManager.answerQuestion(parsed.sessionId, parsed.askId, parsed.answer);
443+
return { ok: true as const };
444+
},
440445
"plan.file.read": async (ctx, payload) => {
441446
const parsed = CommandSchemas["plan.file.read"].request.parse(payload);
442447
const record = ctx.sessionManager.get(parsed.sessionId);

0 commit comments

Comments
 (0)