mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
feat(chat): add ask_user_question tool for structured disambiguation
A global-mode tool that pauses execution and lets the AI ask the user a multiple-choice question (2–4 mutually-exclusive options). Pure frontend, mirrors the existing tool-confirmation flow: - shared.ts: ToolPendingQuestion type; pendingQuestion + answeredValue fields on ToolDisplayMessage; requestAnswer callback on ToolCallbacks. - AIChatManager: answerCallback + requestAnswer / handleToolAnswer pair. Resolves with the picked option's value, or '' on cancel. Cancel sweep clears the callback alongside the existing confirmation cleanup. - ToolExecutionDisplay: when pendingQuestion is set, the card auto- expands and replaces its body with the question text + one button per option. Clicking an option calls aiChatManager.handleToolAnswer. - global/core.ts: askUserQuestionSchema and the tool entry in globalTools. Returns the picked option's value verbatim to the model, or '(user cancelled the question)' if the user cancelled. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
33bf01b627
commit
85218dae5b
@@ -228,6 +228,24 @@ class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Pending resolver for `ask_user_question`. Same shape as
|
||||
// confirmationCallback but resolves with the chosen option value
|
||||
// (a string) instead of a boolean.
|
||||
private answerCallback = $state<((value: string) => void) | undefined>(undefined)
|
||||
|
||||
requestAnswer = (_toolId: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
this.answerCallback = resolve
|
||||
})
|
||||
}
|
||||
|
||||
handleToolAnswer = (_toolId: string, value: string) => {
|
||||
if (this.answerCallback) {
|
||||
this.answerCallback(value)
|
||||
this.answerCallback = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setAiChatInput(aiChatInput: AIChatInput | null) {
|
||||
this.aiChatInput = aiChatInput
|
||||
}
|
||||
@@ -838,7 +856,8 @@ class AIChatManager {
|
||||
this.displayMessages = [...this.displayMessages]
|
||||
}
|
||||
},
|
||||
requestConfirmation: this.requestConfirmation
|
||||
requestConfirmation: this.requestConfirmation,
|
||||
requestAnswer: this.requestAnswer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,6 +888,13 @@ class AIChatManager {
|
||||
this.confirmationCallback(false)
|
||||
this.confirmationCallback = undefined
|
||||
}
|
||||
if (this.answerCallback) {
|
||||
// Resolve with empty string so the awaiting tool fn returns
|
||||
// gracefully; the cancelLoadingTools sweep below will then
|
||||
// flag the message as cancelled.
|
||||
this.answerCallback('')
|
||||
this.answerCallback = undefined
|
||||
}
|
||||
const cancelReason = reason ?? 'user_cancelled'
|
||||
console.log('cancelling request:', {
|
||||
reason: cancelReason,
|
||||
|
||||
@@ -17,10 +17,13 @@
|
||||
message.parameters !== undefined && Object.keys(message.parameters).length > 0
|
||||
)
|
||||
|
||||
const hasPendingQuestion = $derived(!!message.pendingQuestion)
|
||||
|
||||
let isExpanded = $derived(
|
||||
message.showDetails ||
|
||||
(message.isStreamingArguments && hasParameters) ||
|
||||
(message.isLoading && message.needsConfirmation)
|
||||
(message.isLoading && message.needsConfirmation) ||
|
||||
hasPendingQuestion
|
||||
)
|
||||
|
||||
const visibleActions = $derived(
|
||||
@@ -67,70 +70,96 @@
|
||||
<!-- Expanded Content -->
|
||||
{#if isExpanded}
|
||||
<div class="p-2 bg-surface space-y-3">
|
||||
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
|
||||
{#if hasParameters || message.needsConfirmation}
|
||||
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
|
||||
<ToolContentDisplay
|
||||
title="Parameters"
|
||||
content={message.parameters}
|
||||
streaming={message.isStreamingArguments}
|
||||
toolName={message.toolName}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
<!-- User question prompt: takes over the card while the
|
||||
ask_user_question tool is paused waiting for an answer. -->
|
||||
{#if message.pendingQuestion}
|
||||
<div class="space-y-2 font-sans">
|
||||
<div class="text-primary text-sm">{message.pendingQuestion.question}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each message.pendingQuestion.options as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="text-left px-3 py-2 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-surface-hover hover:border-blue-400 transition-colors"
|
||||
onclick={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolAnswer(message.tool_call_id, opt.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="text-primary text-xs font-medium">{opt.label}</div>
|
||||
{#if opt.description}
|
||||
<div class="text-tertiary text-2xs mt-0.5">{opt.description}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
|
||||
{#if hasParameters || message.needsConfirmation}
|
||||
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
|
||||
<ToolContentDisplay
|
||||
title="Parameters"
|
||||
content={message.parameters}
|
||||
streaming={message.isStreamingArguments}
|
||||
toolName={message.toolName}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirmation Footer -->
|
||||
{#if message.needsConfirmation}
|
||||
<div
|
||||
class={twMerge(
|
||||
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
|
||||
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: XCircle }}
|
||||
destructive
|
||||
></Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Play }}
|
||||
<!-- Confirmation Footer -->
|
||||
{#if message.needsConfirmation}
|
||||
<div
|
||||
class={twMerge(
|
||||
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
|
||||
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
)}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: XCircle }}
|
||||
destructive
|
||||
></Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Play }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logs and Result - hide while streaming -->
|
||||
{:else if !message.isStreamingArguments}
|
||||
<ToolContentDisplay
|
||||
title="Logs"
|
||||
content={message.logs}
|
||||
loading={message.isLoading}
|
||||
showWhileLoading={false}
|
||||
/>
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<ToolMessageActions actions={visibleActions} />
|
||||
{:else}
|
||||
<!-- Logs and Result - hide while streaming -->
|
||||
{:else if !message.isStreamingArguments}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={message.result}
|
||||
error={message.error}
|
||||
title="Logs"
|
||||
content={message.logs}
|
||||
loading={message.isLoading}
|
||||
showWhileLoading={false}
|
||||
/>
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<ToolMessageActions actions={visibleActions} />
|
||||
{:else}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={message.result}
|
||||
error={message.error}
|
||||
loading={message.isLoading}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -41,12 +41,7 @@ import {
|
||||
validateEditableFlowJson
|
||||
} from '../flow/editableFlowJson'
|
||||
import { createInlineScriptSession } from '../flow/inlineScriptsUtils'
|
||||
import {
|
||||
getFlowPrompt,
|
||||
getRawAppPrompt,
|
||||
getResourcePrompt,
|
||||
getScriptPrompt
|
||||
} from '$system_prompts'
|
||||
import { getFlowPrompt, getRawAppPrompt, getResourcePrompt, getScriptPrompt } from '$system_prompts'
|
||||
import type {
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam
|
||||
@@ -111,6 +106,34 @@ const getInstructionsSchema = z.object({
|
||||
)
|
||||
})
|
||||
|
||||
const askUserQuestionSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
.describe('The question to put to the user, phrased clearly and ending with a question mark.'),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().describe('Short text shown on the option button (1–6 words).'),
|
||||
value: z
|
||||
.string()
|
||||
.describe(
|
||||
'Machine-readable value returned to the tool when this option is picked. Keep it concise — you will receive this exact string back as the tool result.'
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional one-line explanation rendered below the label to clarify what picking this option means.'
|
||||
)
|
||||
})
|
||||
)
|
||||
.min(2)
|
||||
.max(4)
|
||||
.describe(
|
||||
'2–4 mutually exclusive options for the user to choose from. Order most-recommended first.'
|
||||
)
|
||||
})
|
||||
|
||||
const listWorkspaceItemsSchema = z.object({
|
||||
types: z
|
||||
.array(itemTypeSchema)
|
||||
@@ -141,9 +164,7 @@ const readWorkspaceItemSchema = z.object({
|
||||
})
|
||||
|
||||
const writeScriptSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
|
||||
path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'),
|
||||
summary: z.string().optional().describe('Short human-readable summary.'),
|
||||
language: scriptLangSchema.describe('Script language.'),
|
||||
content: z.string().describe('Full script source code.')
|
||||
@@ -165,7 +186,7 @@ const setFlowModuleCodeSchema = z.object({
|
||||
.describe(
|
||||
'Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is "rawscript". Use patch_flow_json for structural changes.'
|
||||
),
|
||||
code: z.string().describe('New script source. Replaces the module\'s value.content entirely.')
|
||||
code: z.string().describe("New script source. Replaces the module's value.content entirely.")
|
||||
})
|
||||
|
||||
// Flow structure fields are taken as JSON strings rather than typed objects
|
||||
@@ -174,9 +195,7 @@ const setFlowModuleCodeSchema = z.object({
|
||||
// rejects those keywords ("Unknown name $ref/$defs"). Same trick as
|
||||
// set_flow_json in chat/flow/core.ts.
|
||||
const writeFlowSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
|
||||
path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
|
||||
summary: z.string().optional().describe('Short human-readable summary.'),
|
||||
modules: z.string().describe('JSON string containing the complete flow modules array.'),
|
||||
schema: z
|
||||
@@ -423,7 +442,12 @@ const deleteAppRunnableSchema = z.object({
|
||||
key: z.string().describe('Key of the backend runnable to remove.')
|
||||
})
|
||||
|
||||
const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5', 'vue'] as const satisfies readonly FrameworkKey[]
|
||||
const FRAMEWORK_KEYS = [
|
||||
'react19',
|
||||
'react18',
|
||||
'svelte5',
|
||||
'vue'
|
||||
] as const satisfies readonly FrameworkKey[]
|
||||
|
||||
const initAppSchema = z.object({
|
||||
path: z
|
||||
@@ -644,7 +668,7 @@ function buildPersistedRunnable(
|
||||
{ type: 'static', value: v, fieldType: 'object' }
|
||||
])
|
||||
)
|
||||
: existing?.fields ?? {}
|
||||
: (existing?.fields ?? {})
|
||||
|
||||
if (input.type === 'inline') {
|
||||
if (!input.inlineScript) {
|
||||
@@ -698,10 +722,12 @@ type AppMetadata = {
|
||||
}
|
||||
|
||||
function summarizeAppValue(value: AppDraftValue): AppMetadata {
|
||||
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(([path, content]) => ({
|
||||
path,
|
||||
size: typeof content === 'string' ? content.length : 0
|
||||
}))
|
||||
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(
|
||||
([path, content]) => ({
|
||||
path,
|
||||
size: typeof content === 'string' ? content.length : 0
|
||||
})
|
||||
)
|
||||
const backend: AppBackendRunnableMetadata[] = Object.entries(value.runnables).map(
|
||||
([key, runnable]) => {
|
||||
const converted = convertPersistedToBackendRunnable(runnable as PersistedRunnable, key)
|
||||
@@ -849,11 +875,7 @@ function triggerToItem(
|
||||
type TriggerService = {
|
||||
exists(args: { workspace: string; path: string }): Promise<boolean>
|
||||
get(args: { workspace: string; path: string }): Promise<TriggerLike>
|
||||
list(args: {
|
||||
workspace: string
|
||||
pathStart?: string
|
||||
perPage?: number
|
||||
}): Promise<TriggerLike[]>
|
||||
list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise<TriggerLike[]>
|
||||
create(args: { workspace: string; requestBody: any }): Promise<string>
|
||||
update(args: { workspace: string; path: string; requestBody: any }): Promise<string>
|
||||
delete(args: { workspace: string; path: string }): Promise<string>
|
||||
@@ -983,7 +1005,7 @@ async function readWorkspaceItem(
|
||||
)
|
||||
case 'resource':
|
||||
return resourceToItem(
|
||||
await ResourceService.getResource({ workspace, path }) as ListableResource,
|
||||
(await ResourceService.getResource({ workspace, path })) as ListableResource,
|
||||
true
|
||||
)
|
||||
case 'variable':
|
||||
@@ -1205,6 +1227,39 @@ export const globalTools: Tool<{}>[] = [
|
||||
return getInstructions(parsed.subject, parsed.language)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
askUserQuestionSchema,
|
||||
'ask_user_question',
|
||||
"Ask the user a multiple-choice question and wait for their reply. Use this when you need to disambiguate before continuing — for example to pick a target folder/path, choose between framework options, or confirm a non-obvious tradeoff. Provide 2–4 mutually exclusive options; do NOT use this for free-form questions or yes/no confirmations (regular text or the tool-confirmation flow is better for those). The tool result is the picked option's value verbatim, or empty string if the user cancelled."
|
||||
),
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const parsed = askUserQuestionSchema.parse(args)
|
||||
if (!toolCallbacks.requestAnswer) {
|
||||
throw new Error('ask_user_question is not supported in this chat mode')
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: parsed.question,
|
||||
pendingQuestion: { question: parsed.question, options: parsed.options }
|
||||
})
|
||||
const answer = await toolCallbacks.requestAnswer(toolId, {
|
||||
question: parsed.question,
|
||||
options: parsed.options
|
||||
})
|
||||
const picked = parsed.options.find((o) => o.value === answer)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: picked
|
||||
? `User picked: ${picked.label}`
|
||||
: answer
|
||||
? `User answer: ${answer}`
|
||||
: 'User cancelled',
|
||||
pendingQuestion: undefined,
|
||||
answeredValue: answer,
|
||||
result: answer
|
||||
})
|
||||
return answer || '(user cancelled the question)'
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
listWorkspaceItemsSchema,
|
||||
@@ -1275,12 +1330,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Reading ${parsed.type} "${parsed.path}"...`
|
||||
})
|
||||
const item = await readWorkspaceItem(
|
||||
parsed.type,
|
||||
parsed.path,
|
||||
workspace,
|
||||
parsed.trigger_kind
|
||||
)
|
||||
const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind)
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` })
|
||||
return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2)
|
||||
}
|
||||
@@ -1980,13 +2030,21 @@ async function patchAppFile(
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const { path, file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = args
|
||||
const {
|
||||
path,
|
||||
file_path: filePath,
|
||||
old_string: oldString,
|
||||
new_string: newString,
|
||||
replace_all: replaceAll
|
||||
} = args
|
||||
const target = resolveAppFileTarget(filePath)
|
||||
if (target.kind === 'frontend') {
|
||||
assertNotGeneratedAppFile(target.filePath)
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Patching ${target.filePath} in app "${path}"...` })
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Patching ${target.filePath} in app "${path}"...`
|
||||
})
|
||||
|
||||
const value = await loadAppDraftValue(path, workspace)
|
||||
let currentContent: string
|
||||
@@ -2020,7 +2078,8 @@ async function patchAppFile(
|
||||
[target.key]: {
|
||||
...runnable!,
|
||||
inlineScript: {
|
||||
language: runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
|
||||
language:
|
||||
runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'),
|
||||
content: updated
|
||||
}
|
||||
}
|
||||
@@ -2044,10 +2103,7 @@ async function patchAppFile(
|
||||
}
|
||||
|
||||
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
|
||||
value.policy = (await updateRawAppPolicy(
|
||||
value.runnables as any,
|
||||
value.policy as any
|
||||
)) as any
|
||||
value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any
|
||||
}
|
||||
|
||||
async function writeAppRunnable(
|
||||
@@ -2128,10 +2184,7 @@ const triggerLabels: Record<TriggerKind, string> = {
|
||||
azure: 'Azure Event Grid trigger'
|
||||
}
|
||||
|
||||
function createOpenScheduleAction(
|
||||
path: string,
|
||||
targetKind: 'script' | 'flow'
|
||||
): ToolDisplayAction {
|
||||
function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction {
|
||||
return {
|
||||
id: `open-deployed-schedule:${path}`,
|
||||
type: 'open_created_resource',
|
||||
|
||||
@@ -485,6 +485,20 @@ export type CreatedResourceAction = {
|
||||
|
||||
export type ToolDisplayAction = CreatedResourceAction
|
||||
|
||||
// A multiple-choice question the assistant put to the user via the
|
||||
// `ask_user_question` tool. While `pendingQuestion` is set the tool
|
||||
// call is paused — the UI renders the options and clicking one resolves
|
||||
// the tool's awaiting promise with the selected value.
|
||||
export type ToolQuestionOption = {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
}
|
||||
export type ToolPendingQuestion = {
|
||||
question: string
|
||||
options: ToolQuestionOption[]
|
||||
}
|
||||
|
||||
export type ToolDisplayMessage = {
|
||||
role: 'tool'
|
||||
tool_call_id: string
|
||||
@@ -495,6 +509,8 @@ export type ToolDisplayMessage = {
|
||||
isLoading?: boolean
|
||||
error?: string
|
||||
needsConfirmation?: boolean
|
||||
pendingQuestion?: ToolPendingQuestion
|
||||
answeredValue?: string
|
||||
showDetails?: boolean
|
||||
isStreamingArguments?: boolean
|
||||
toolName?: string
|
||||
@@ -684,6 +700,10 @@ export interface ToolCallbacks {
|
||||
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
|
||||
removeToolStatus: (id: string) => void
|
||||
requestConfirmation?: (toolId: string) => Promise<boolean>
|
||||
// Used by the `ask_user_question` tool. Suspends the tool until the
|
||||
// user picks one of the supplied options. Resolves with the chosen
|
||||
// value string, or empty string when the user cancels.
|
||||
requestAnswer?: (toolId: string, question: ToolPendingQuestion) => Promise<string>
|
||||
}
|
||||
|
||||
export function createToolDef(
|
||||
|
||||
Reference in New Issue
Block a user