From ddbfabcc350e4dba85eb8472cf76fc41ffb7d414 Mon Sep 17 00:00:00 2001 From: udo Date: Sun, 28 Jun 2026 20:57:24 +0000 Subject: [PATCH] Add request choice tool --- client/app.css | 46 +++++++++++++++++++++ client/main.ts | 35 ++++++++++++++++ server/index.ts | 99 ++++++++++++++++++++++++++++++++++++++++++---- shared/protocol.ts | 3 +- 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/client/app.css b/client/app.css index 7f87c06..97df364 100644 --- a/client/app.css +++ b/client/app.css @@ -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); } diff --git a/client/main.ts b/client/main.ts index fdd01ea..2dbb38d 100644 --- a/client/main.ts +++ b/client/main.ts @@ -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` +
+
${pendingChoice.prompt || "Please choose an option."}
+
+ ${(pendingChoice.choices || []).map((choice: any) => html` + + `)} +
+
+ `; +} + function renderApp() { const app = document.getElementById("app"); if (!app) return; @@ -932,6 +966,7 @@ function renderApp() { .pendingToolCalls=${new Set()} .toolResultsById=${toolResultsById} > + ${renderChoiceRequest()} `} diff --git a/server/index.ts b/server/index.ts index d80dc7b..fc93b38 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 }; const clients = new Map(); const syntheticModels = new Map>(); let sessionListCache: { expires: number; files: { filePath: string; mtime: Date }[] } | undefined; @@ -561,15 +569,59 @@ async function resolveInitialWebUiModel(): Promise | 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(); + 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) { diff --git a/shared/protocol.ts b/shared/protocol.ts index 06d9408..47b2424 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -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 ──