Add request choice tool

This commit is contained in:
udo committed 2026-06-28 20:57:24 +00:00
1 parent 8cb108dafe
commit ddbfabcc35
4 files changed
+174 -9

No files matched your search

+46
View File
@@ -378,6 +378,52 @@ textarea {
50% { opacity: 1; }
}
.choice-request-card {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
color: var(--card-foreground);
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.choice-request-title {
font-weight: 600;
margin-bottom: 0.6rem;
}
.choice-request-options {
display: grid;
gap: 0.5rem;
}
.choice-request-option {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: flex-start;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--foreground);
padding: 0.55rem 0.65rem;
text-align: left;
cursor: pointer;
}
.choice-request-option:hover {
background: var(--accent);
}
.choice-request-label {
font-weight: 600;
}
.choice-request-description {
color: var(--muted-foreground);
font-size: 0.8rem;
}
.model-option-current {
background: color-mix(in oklab, var(--accent) 50%, transparent);
}
+35
View File
@@ -287,6 +287,7 @@ let sessionsHasMore = false;
let sessionsOffset = 0;
let sessionSearch = "";
let sessionSearchTimer: number | undefined;
let pendingChoice: any | null = null;
// ── WebSocket ──
@@ -522,6 +523,15 @@ function handleAgentEvent(event: any) {
renderApp();
break;
case "choice_request":
pendingChoice = event.request;
renderApp();
requestAnimationFrame(() => scrollMessagesToBottom(true));
break;
case "choice_resolved":
if (pendingChoice?.id === event.requestId) pendingChoice = null;
renderApp();
break;
case "tool_execution_start":
if (typeof event.toolCallId === "string" && !seenToolCallIds.has(event.toolCallId)) {
collapsePreviousToolCallsFor(event.toolCallId);
@@ -779,6 +789,30 @@ function renderSidebar() {
`;
}
function handleChoiceResponse(request: any, choiceId: string) {
const selected = request?.allowMultiple ? [choiceId] : [choiceId];
send({ type: "choiceResponse", requestId: String(request.id), selected });
pendingChoice = null;
renderApp();
}
function renderChoiceRequest() {
if (!pendingChoice) return nothing;
return html`
<div class="choice-request-card" role="group" aria-label="Agent choice request">
<div class="choice-request-title">${pendingChoice.prompt || "Please choose an option."}</div>
<div class="choice-request-options">
${(pendingChoice.choices || []).map((choice: any) => html`
<button class="choice-request-option" type="button" @click=${() => handleChoiceResponse(pendingChoice, choice.id)}>
<span class="choice-request-label">${choice.label || choice.id}</span>
${choice.description ? html`<span class="choice-request-description">${choice.description}</span>` : nothing}
</button>
`)}
</div>
</div>
`;
}
function renderApp() {
const app = document.getElementById("app");
if (!app) return;
@@ -932,6 +966,7 @@ function renderApp() {
.pendingToolCalls=${new Set()}
.toolResultsById=${toolResultsById}
></streaming-message-container>
${renderChoiceRequest()}
</div>
`}
</div>
+91 -8
View File
@@ -11,10 +11,11 @@ import {
createAgentSession,
ModelRegistry,
SessionManager,
defineTool,
type AgentSession,
type AgentSessionEvent,
} from "@mariozechner/pi-coding-agent";
import type { Model, Api } from "@mariozechner/pi-ai";
import { Type, type Model, type Api } from "@mariozechner/pi-ai";
import type {
ClientMessage,
ServerMessage,
@@ -93,7 +94,14 @@ const SESSION_LIST_CACHE_MS = parseIntEnv("SESSION_LIST_CACHE_MS", 10000);
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
type ClientRuntime = { session: AgentSession; unsubscribe?: () => void };
type ChoiceRequest = {
id: string;
prompt: string;
choices: Array<{ id: string; label: string; description?: string }>;
allowMultiple: boolean;
};
type ClientRuntime = { session: AgentSession; unsubscribe?: () => void; pendingChoices?: Map<string, ChoiceRequest> };
const clients = new Map<WebSocket, ClientRuntime>();
const syntheticModels = new Map<string, Model<Api>>();
let sessionListCache: { expires: number; files: { filePath: string; mtime: Date }[] } | undefined;
@@ -561,15 +569,59 @@ async function resolveInitialWebUiModel(): Promise<Model<Api> | undefined> {
|| available.find((m) => modelRegistry.hasConfiguredAuth(m));
}
async function createWebUiSession(sessionManager: SessionManager) {
const requestChoiceParams = Type.Object({
prompt: Type.String({ description: "Short question shown to the user." }),
choices: Type.Array(Type.Object({
id: Type.String({ description: "Stable machine-readable choice id." }),
label: Type.String({ description: "Short button label." }),
description: Type.Optional(Type.String({ description: "Optional one-line explanation." })),
}), { description: "2-6 concrete choices." }),
allowMultiple: Type.Optional(Type.Boolean({ description: "Whether the user may choose multiple options. Default false." })),
});
function createRequestChoiceTool(onChoiceRequest: (request: ChoiceRequest) => void) {
return defineTool({
name: "request-choice",
label: "request-choice",
description: "Ask the user to choose from concrete options when guessing would be risky.",
promptSnippet: "request-choice: ask the user to choose one or more concrete options.",
promptGuidelines: [
"Use request-choice for discrete user decisions where guessing would be risky. Keep choices few and concrete.",
"After calling request-choice, stop and wait for the user's choice response instead of continuing to guess.",
],
parameters: requestChoiceParams,
async execute(toolCallId, params) {
const choices = (params.choices || []).slice(0, 8).map((choice: any, index: number) => ({
id: String(choice.id || `choice-${index + 1}`).trim().slice(0, 80),
label: String(choice.label || choice.id || `Choice ${index + 1}`).trim().slice(0, 120),
description: choice.description ? String(choice.description).trim().slice(0, 300) : undefined,
})).filter((choice: any) => choice.id && choice.label);
const request: ChoiceRequest = {
id: toolCallId,
prompt: String(params.prompt || "Please choose an option.").trim().slice(0, 500),
choices,
allowMultiple: Boolean(params.allowMultiple),
};
onChoiceRequest(request);
const data = { type: "waiting_for_user_choice", request };
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], details: data };
},
});
}
async function createWebUiSession(sessionManager: SessionManager, onChoiceRequest: (request: ChoiceRequest) => void) {
const initialModel = await resolveInitialWebUiModel();
return createAgentSession({
const customTools = [createRequestChoiceTool(onChoiceRequest)];
const result = await createAgentSession({
cwd: HOME_DIR,
authStorage,
modelRegistry,
sessionManager,
model: initialModel,
customTools,
});
result.session.setActiveToolsByName([...new Set([...result.session.getActiveToolNames(), ...customTools.map((tool) => tool.name)])]);
return result;
}
function safeSerializeEvent(event: AgentSessionEvent): any {
@@ -624,6 +676,8 @@ function isClientMessage(value: any): value is ClientMessage {
return typeof value.level === "string";
case "loadSession":
return typeof value.sessionPath === "string";
case "choiceResponse":
return typeof value.requestId === "string" && Array.isArray(value.selected) && value.selected.every((item: any) => typeof item === "string");
case "getMessages":
return typeof value.offset === "number";
case "abort":
@@ -662,6 +716,25 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
await session.followUp(msg.text);
break;
}
case "choiceResponse": {
const request = runtime.pendingChoices?.get(msg.requestId);
if (!request) {
send(ws, { type: "error", message: "Choice request is no longer active" });
break;
}
const allowed = new Map(request.choices.map((choice) => [choice.id, choice]));
const selected = msg.selected.filter((id) => allowed.has(id));
if (!request.allowMultiple && selected.length > 1) selected.splice(1);
if (selected.length === 0) {
send(ws, { type: "error", message: "Please choose one of the listed options" });
break;
}
runtime.pendingChoices?.delete(msg.requestId);
const labels = selected.map((id) => `${id} (${allowed.get(id)?.label || id})`).join(", ");
await session.followUp(`Choice response for ${msg.requestId}: ${labels}`);
send(ws, { type: "agentEvent", event: { type: "choice_resolved", requestId: msg.requestId, selected } });
break;
}
case "abort": {
await session.abort();
send(ws, { type: "stateSync", state: serializeState(session) });
@@ -716,7 +789,10 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
await session.abort();
}
const { session: newSession } = await createWebUiSession(SessionManager.create(HOME_DIR));
const { session: newSession } = await createWebUiSession(SessionManager.create(HOME_DIR), (request) => {
runtime.pendingChoices?.set(request.id, request);
send(ws, { type: "agentEvent", event: { type: "choice_request", request } });
});
runtime.session = newSession;
session = runtime.session;
clearSessionListCache();
@@ -748,7 +824,10 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
}
const loadedManager = SessionManager.open(requestedPath, undefined, HOME_DIR);
const { session: loadedSession } = await createWebUiSession(loadedManager);
const { session: loadedSession } = await createWebUiSession(loadedManager, (request) => {
runtime.pendingChoices?.set(request.id, request);
send(ws, { type: "agentEvent", event: { type: "choice_request", request } });
});
runtime.session = loadedSession;
session = runtime.session;
clearSessionListCache();
@@ -782,8 +861,12 @@ async function main() {
try {
const url = new URL(request.url || "/api/ws", `http://${request.headers.host || "localhost"}`);
const sessionManager = sessionManagerForResumePath(url.searchParams.get("sessionPath") || undefined);
const { session, modelFallbackMessage } = await createWebUiSession(sessionManager);
const runtime: ClientRuntime = { session };
const pendingChoices = new Map<string, ChoiceRequest>();
const { session, modelFallbackMessage } = await createWebUiSession(sessionManager, (request) => {
pendingChoices.set(request.id, request);
send(ws, { type: "agentEvent", event: { type: "choice_request", request } });
});
const runtime: ClientRuntime = { session, pendingChoices };
clients.set(ws, runtime);
if (modelFallbackMessage) {
+2 -1
View File
@@ -12,7 +12,8 @@ export type ClientMessage =
| { type: "newSession" }
| { type: "getSessions"; offset?: number; limit?: number; query?: string }
| { type: "getMessages"; offset: number; limit?: number }
| { type: "loadSession"; sessionPath: string };
| { type: "loadSession"; sessionPath: string }
| { type: "choiceResponse"; requestId: string; selected: string[] };
// ── Server → Client messages ──