Archived
feat(sessions): add the ask_user tool
Register a core ask_user custom tool that posts a question set to the user's browser and terminates the run instead of awaiting an answer. The tool is thin: it shapes its TypeBox params into domain questions, lets PendingAskStore own validation, and reports a superseded unanswered ask back to the model. Gated by the askUser config key, threaded through PiSessionServiceDependencies and sessiond. Unlike the delegation tools, ask_user is available to tracked children too: the questions reach the user of the asking session.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { Type, type Static } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
ASK_USER_ID_MAX_LENGTH,
|
||||
ASK_USER_OPTION_LIMIT,
|
||||
ASK_USER_QUESTION_LIMIT,
|
||||
ASK_USER_TEXT_MAX_LENGTH,
|
||||
type AskUserQuestion,
|
||||
type AskUserQuestionOption,
|
||||
} from "../../shared/apiTypes.js";
|
||||
import { renderSupersededAskText, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||
|
||||
/** One `ask_user` call: the questions to post, for the session that called the tool. */
|
||||
export interface AskUserInvocation {
|
||||
sessionId: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
export interface AskUserToolDeps {
|
||||
/** Registers the ask as the session's open one; rejects question sets the user could not answer. */
|
||||
open(input: AskUserInvocation): Promise<PendingAskOpenResult>;
|
||||
}
|
||||
|
||||
type AskUserToolDetails = PendingAskOpenResult;
|
||||
|
||||
const AskUserOptionParams = Type.Object({
|
||||
value: Type.String({
|
||||
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||
description: "Stable machine value reported back to you when the user picks this option.",
|
||||
}),
|
||||
label: Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Short label the user reads, ideally a few words.",
|
||||
}),
|
||||
detail: Type.Optional(Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Optional clarification shown under the label.",
|
||||
})),
|
||||
});
|
||||
|
||||
const AskUserQuestionParams = Type.Object({
|
||||
id: Type.String({
|
||||
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||
description: "Unique within this call; used as the answer key reported back to you.",
|
||||
}),
|
||||
question: Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "The question itself, as one plain-text line.",
|
||||
}),
|
||||
detail: Type.Optional(Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Optional supporting context shown under the question.",
|
||||
})),
|
||||
options: Type.Optional(Type.Array(AskUserOptionParams, {
|
||||
maxItems: ASK_USER_OPTION_LIMIT,
|
||||
description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.",
|
||||
})),
|
||||
allowOther: Type.Optional(Type.Boolean({
|
||||
description: "Offer a free-text field alongside the options.",
|
||||
})),
|
||||
multiple: Type.Optional(Type.Boolean({
|
||||
description: "Allow several options at once. Default: one answer per question.",
|
||||
})),
|
||||
});
|
||||
|
||||
const AskUserParams = Type.Object({
|
||||
questions: Type.Array(AskUserQuestionParams, {
|
||||
minItems: 1,
|
||||
maxItems: ASK_USER_QUESTION_LIMIT,
|
||||
description: "The questions to post, in the order the user should read them. Every question may be left unanswered.",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Shapes one schema question into the domain question; the store owns validation. */
|
||||
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
|
||||
const { detail, options, allowOther, multiple } = param;
|
||||
return {
|
||||
id: param.id,
|
||||
question: param.question,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
options: (options ?? []).map(toOption),
|
||||
...(allowOther === undefined ? {} : { allowOther }),
|
||||
...(multiple === undefined ? {} : { multiple }),
|
||||
};
|
||||
}
|
||||
|
||||
function toOption(param: Static<typeof AskUserOptionParams>): AskUserQuestionOption {
|
||||
const { detail } = param;
|
||||
return { value: param.value, label: param.label, ...(detail === undefined ? {} : { detail }) };
|
||||
}
|
||||
|
||||
function postedText(result: PendingAskOpenResult): string {
|
||||
const count = result.ask.questions.length;
|
||||
const posted = `Posted ${count.toString()} question${count === 1 ? "" : "s"} to the user as ask ${result.ask.askId}. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.`;
|
||||
return result.superseded === undefined ? posted : `${posted}\n\n${renderSupersededAskText(result.superseded)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom tool that posts a question set to the user's browser as interactive UI.
|
||||
*
|
||||
* It deliberately does **not** await the user. Awaiting would pin the agent run
|
||||
* for an unbounded human-scale wait, keep the session streaming, and leave a
|
||||
* dangling tool call if the runtime were replaced meanwhile. Instead the ask
|
||||
* becomes daemon-owned state, the tool terminates the run, and the submitted
|
||||
* answers return later as a follow-up message that wakes the session.
|
||||
*
|
||||
* Rejected question sets throw: the agent loop turns the thrown message into an
|
||||
* error tool result, so the model can fix the ask and post it again.
|
||||
*/
|
||||
export function createAskUserToolDefinition(deps: AskUserToolDeps) {
|
||||
return defineTool<typeof AskUserParams, AskUserToolDetails>({
|
||||
name: "ask_user",
|
||||
label: "Ask user",
|
||||
description: "Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.",
|
||||
promptSnippet: "ask_user: post a question set to the user; ends the run, answers return as a follow-up",
|
||||
promptGuidelines: [
|
||||
"When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.",
|
||||
],
|
||||
parameters: AskUserParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const result = await deps.open({
|
||||
sessionId: ctx.sessionManager.getSessionId(),
|
||||
questions: params.questions.map(toQuestion),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: postedText(result) }],
|
||||
details: result,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user