mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
feat: add global ask user question tool (#9217)
* feat: add global ask user question tool * feat: add keyboard navigation to user questions * feat: simplify ask user question answers * fix: disable strict mode for optional tool schemas * fix: scope ask question keyboard events * fix: clean up ask question display state
This commit is contained in:
@@ -142,6 +142,7 @@ class AIChatManager {
|
||||
cachedDatatables = $state<AppDatatableElement[]>([])
|
||||
|
||||
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
|
||||
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
@@ -228,6 +229,40 @@ class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
requestUserQuestion = (
|
||||
toolId: string,
|
||||
_question: { question: string; choices: string[] }
|
||||
): Promise<string | undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
this.userQuestionCallbacks.set(toolId, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
handleUserQuestionAnswer = (toolId: string, choice: string) => {
|
||||
const callback = this.userQuestionCallbacks.get(toolId)
|
||||
if (!callback) {
|
||||
return
|
||||
}
|
||||
|
||||
this.displayMessages = this.displayMessages.map((message) => {
|
||||
if (message.role === 'tool' && message.tool_call_id === toolId && message.userQuestion) {
|
||||
return {
|
||||
...message,
|
||||
content: `User answered question: ${choice}`,
|
||||
isLoading: false,
|
||||
userQuestion: {
|
||||
...message.userQuestion,
|
||||
selectedChoice: choice
|
||||
}
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
callback(choice)
|
||||
this.userQuestionCallbacks.delete(toolId)
|
||||
}
|
||||
|
||||
setAiChatInput(aiChatInput: AIChatInput | null) {
|
||||
this.aiChatInput = aiChatInput
|
||||
}
|
||||
@@ -838,7 +873,8 @@ class AIChatManager {
|
||||
this.displayMessages = [...this.displayMessages]
|
||||
}
|
||||
},
|
||||
requestConfirmation: this.requestConfirmation
|
||||
requestConfirmation: this.requestConfirmation,
|
||||
requestUserQuestion: this.requestUserQuestion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,6 +905,10 @@ class AIChatManager {
|
||||
this.confirmationCallback(false)
|
||||
this.confirmationCallback = undefined
|
||||
}
|
||||
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
|
||||
resolveQuestion(undefined)
|
||||
}
|
||||
this.userQuestionCallbacks.clear()
|
||||
const cancelReason = reason ?? 'user_cancelled'
|
||||
console.log('cancelling request:', {
|
||||
reason: cancelReason,
|
||||
@@ -1207,7 +1247,10 @@ class AIChatManager {
|
||||
...message,
|
||||
isLoading: false,
|
||||
content: messageText,
|
||||
error: messageText
|
||||
error: messageText,
|
||||
userQuestion: message.userQuestion
|
||||
? { ...message.userQuestion, canceled: true }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
return message
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte'
|
||||
import { CircleHelp } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import type { UserQuestionDisplay } from './shared'
|
||||
|
||||
interface Props {
|
||||
toolCallId: string
|
||||
userQuestion: UserQuestionDisplay
|
||||
}
|
||||
|
||||
let { toolCallId, userQuestion }: Props = $props()
|
||||
|
||||
let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([])
|
||||
|
||||
onMount(() => {
|
||||
if (userQuestion.choices.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
void tick().then(() => {
|
||||
focusChoice(0)
|
||||
})
|
||||
})
|
||||
|
||||
function focusChoice(index: number) {
|
||||
choiceButtons[index]?.focus()
|
||||
}
|
||||
|
||||
function selectChoice(choice: string) {
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, choice)
|
||||
}
|
||||
|
||||
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
focusChoice((index + 1) % userQuestion.choices.length)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
focusChoice((index - 1 + userQuestion.choices.length) % userQuestion.choices.length)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectChoice(choice)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-md border border-gray-200 bg-surface p-3 text-sm dark:border-gray-700"
|
||||
data-chat-keyboard-scope="ask-user-question"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<CircleHelp class="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
|
||||
<p class="min-w-0 flex-1 whitespace-pre-wrap text-xs font-medium text-primary"
|
||||
>{userQuestion.question}</p
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each userQuestion.choices as choice, index (index)}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
bind:element={choiceButtons[index]}
|
||||
onClick={() => selectChoice(choice)}
|
||||
onkeydown={(event) => handleChoiceKeydown(event, choice, index)}
|
||||
btnClasses="!h-auto min-h-[40px] !items-start !justify-start !px-3 !py-2 !text-left !whitespace-normal"
|
||||
>
|
||||
<span class="flex min-w-0 flex-col items-start gap-0.5">
|
||||
<span class="max-w-full break-words text-2xs font-medium">{choice}</span>
|
||||
</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
|
||||
interface Props {
|
||||
message: ToolDisplayMessage
|
||||
@@ -28,111 +29,125 @@
|
||||
? message.actions
|
||||
: []
|
||||
)
|
||||
|
||||
const activeUserQuestion = $derived(
|
||||
message.userQuestion &&
|
||||
message.isLoading &&
|
||||
!message.error &&
|
||||
!message.userQuestion.selectedChoice &&
|
||||
!message.userQuestion.canceled
|
||||
? message.userQuestion
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
|
||||
>
|
||||
<!-- Collapsible Header -->
|
||||
<button
|
||||
class={twMerge(
|
||||
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!message.showDetails && !message.isStreamingArguments}
|
||||
{#if activeUserQuestion}
|
||||
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
{#if message.showDetails || message.isStreamingArguments}
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-3 h-3 text-secondary" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3 text-secondary" />
|
||||
<!-- Collapsible Header -->
|
||||
<button
|
||||
class={twMerge(
|
||||
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!message.showDetails && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
{#if message.showDetails || message.isStreamingArguments}
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-3 h-3 text-secondary" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3 text-secondary" />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{:else if message.error}
|
||||
<span class="text-red-500">✗</span>
|
||||
{:else if !message.isLoading && !message.error}
|
||||
<span class="text-green-500">✓</span>
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{:else if message.error}
|
||||
<span class="text-red-500">✗</span>
|
||||
{:else if !message.isLoading && !message.error}
|
||||
<span class="text-green-500">✓</span>
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 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}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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}
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -70,12 +70,16 @@ function getGlobalTool(name: string): Tool<{}> {
|
||||
return tool
|
||||
}
|
||||
|
||||
async function callGlobalTool(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
async function callGlobalTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
callbacks: ToolCallbacks = toolCallbacks
|
||||
): Promise<string> {
|
||||
return getGlobalTool(name).fn({
|
||||
args,
|
||||
workspace: WORKSPACE,
|
||||
helpers: {},
|
||||
toolCallbacks,
|
||||
toolCallbacks: callbacks,
|
||||
toolId: `test-${name}`
|
||||
})
|
||||
}
|
||||
@@ -198,4 +202,39 @@ describe('global AI tools', () => {
|
||||
})
|
||||
expect(item.value.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks the user a multiple-choice question and returns the selected answer', async () => {
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
removeToolStatus: vi.fn(),
|
||||
requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[1])
|
||||
}
|
||||
|
||||
const raw = await callGlobalTool(
|
||||
'askUserQuestion',
|
||||
{
|
||||
question: 'Which script language should be used?',
|
||||
choices: ['bun', 'python3']
|
||||
},
|
||||
callbacks
|
||||
)
|
||||
|
||||
expect(raw).toBe('python3')
|
||||
expect(callbacks.requestUserQuestion).toHaveBeenCalledWith(
|
||||
'test-askUserQuestion',
|
||||
expect.objectContaining({
|
||||
question: 'Which script language should be used?',
|
||||
choices: ['bun', 'python3']
|
||||
})
|
||||
)
|
||||
expect(callbacks.setToolStatus).toHaveBeenLastCalledWith(
|
||||
'test-askUserQuestion',
|
||||
expect.objectContaining({
|
||||
content: 'User answered question: python3',
|
||||
isLoading: false,
|
||||
result: 'python3',
|
||||
userQuestion: expect.objectContaining({ selectedChoice: 'python3' })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +111,18 @@ const getInstructionsSchema = z.object({
|
||||
)
|
||||
})
|
||||
|
||||
const askUserQuestionSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The concise question to show to the user before continuing.'),
|
||||
choices: z
|
||||
.array(z.string().min(1).describe('Short answer text shown to the user and returned as-is.'))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.describe('Two to six mutually exclusive answer strings.')
|
||||
})
|
||||
|
||||
const listWorkspaceItemsSchema = z.object({
|
||||
types: z
|
||||
.array(itemTypeSchema)
|
||||
@@ -469,6 +481,7 @@ Important rules:
|
||||
- Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match.
|
||||
- Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read.
|
||||
- Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field.
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing.
|
||||
- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions.
|
||||
- Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend/<key>/main.{ts|py}". /wmill.d.ts is generated and cannot be written.
|
||||
- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice.
|
||||
@@ -1205,6 +1218,60 @@ export const globalTools: Tool<{}>[] = [
|
||||
return getInstructions(parsed.subject, parsed.language)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
askUserQuestionSchema,
|
||||
'askUserQuestion',
|
||||
'Ask the user a multiple-choice question and wait for their selection before continuing.'
|
||||
),
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const parsed = askUserQuestionSchema.parse(args)
|
||||
const userQuestion = {
|
||||
question: parsed.question,
|
||||
choices: parsed.choices
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: parsed.question,
|
||||
userQuestion,
|
||||
isLoading: true
|
||||
})
|
||||
|
||||
if (!toolCallbacks.requestUserQuestion) {
|
||||
const message = 'This chat context cannot ask interactive questions.'
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: message,
|
||||
userQuestion: { ...userQuestion, canceled: true },
|
||||
isLoading: false,
|
||||
error: message
|
||||
})
|
||||
return JSON.stringify({ success: false, error: message })
|
||||
}
|
||||
|
||||
const selectedChoice = await toolCallbacks.requestUserQuestion(toolId, userQuestion)
|
||||
if (!selectedChoice) {
|
||||
const message = 'Question cancelled by user'
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: message,
|
||||
userQuestion: { ...userQuestion, canceled: true },
|
||||
isLoading: false,
|
||||
error: message
|
||||
})
|
||||
return JSON.stringify({ success: false, error: message })
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `User answered question: ${selectedChoice}`,
|
||||
userQuestion: {
|
||||
...userQuestion,
|
||||
selectedChoice
|
||||
},
|
||||
result: selectedChoice,
|
||||
isLoading: false
|
||||
})
|
||||
return selectedChoice
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
listWorkspaceItemsSchema,
|
||||
|
||||
@@ -68,6 +68,39 @@ describe('createToolDef', () => {
|
||||
expect(parameters?.properties?.config?.anyOf?.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('disables strict mode for schemas with optional properties', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const toolDef = createToolDef(
|
||||
z.object({
|
||||
subject: z.string(),
|
||||
language: z.string().optional()
|
||||
}),
|
||||
'get_instructions',
|
||||
'Get instructions'
|
||||
)
|
||||
|
||||
const parameters = toolDef.function.parameters as any
|
||||
expect(toolDef.function.strict).toBe(false)
|
||||
expect(parameters.required).toEqual(['subject'])
|
||||
expect(parameters.properties.language.type).toBe('string')
|
||||
})
|
||||
|
||||
it('keeps strict mode for schemas without optional properties', async () => {
|
||||
const { createToolDef } = await import('./shared')
|
||||
const toolDef = createToolDef(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
choices: z.array(z.string())
|
||||
}),
|
||||
'askUserQuestion',
|
||||
'Ask a question'
|
||||
)
|
||||
|
||||
const parameters = toolDef.function.parameters as any
|
||||
expect(toolDef.function.strict).toBe(true)
|
||||
expect(parameters.required).toEqual(['question', 'choices'])
|
||||
})
|
||||
|
||||
it('does not expose runnable target fields on workspace mutation tools', async () => {
|
||||
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
||||
const [scheduleTool, triggerTool] = createWorkspaceMutationTools()
|
||||
|
||||
@@ -485,6 +485,13 @@ export type CreatedResourceAction = {
|
||||
|
||||
export type ToolDisplayAction = CreatedResourceAction
|
||||
|
||||
export type UserQuestionDisplay = {
|
||||
question: string
|
||||
choices: string[]
|
||||
selectedChoice?: string
|
||||
canceled?: boolean
|
||||
}
|
||||
|
||||
export type ToolDisplayMessage = {
|
||||
role: 'tool'
|
||||
tool_call_id: string
|
||||
@@ -500,6 +507,7 @@ export type ToolDisplayMessage = {
|
||||
toolName?: string
|
||||
showFade?: boolean
|
||||
actions?: ToolDisplayAction[]
|
||||
userQuestion?: UserQuestionDisplay
|
||||
}
|
||||
|
||||
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
@@ -684,6 +692,10 @@ export interface ToolCallbacks {
|
||||
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
|
||||
removeToolStatus: (id: string) => void
|
||||
requestConfirmation?: (toolId: string) => Promise<boolean>
|
||||
requestUserQuestion?: (
|
||||
toolId: string,
|
||||
question: UserQuestionDisplay
|
||||
) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
export function createToolDef(
|
||||
@@ -697,11 +709,12 @@ export function createToolDef(
|
||||
delete parameters.$schema
|
||||
if (!parameters.required) parameters.required = []
|
||||
normalizeToolParameterSchema(parameters)
|
||||
const effectiveStrict = strict && !hasOptionalProperties(parameters)
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
strict,
|
||||
strict: effectiveStrict,
|
||||
name,
|
||||
description,
|
||||
parameters
|
||||
@@ -709,6 +722,54 @@ export function createToolDef(
|
||||
}
|
||||
}
|
||||
|
||||
function hasOptionalProperties(schema: Record<string, any> | undefined): boolean {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (schema.properties && typeof schema.properties === 'object') {
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
||||
const propertyKeys = Object.keys(schema.properties)
|
||||
if (propertyKeys.some((key) => !required.has(key))) {
|
||||
return true
|
||||
}
|
||||
for (const key of propertyKeys) {
|
||||
if (hasOptionalProperties(schema.properties[key])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
if (schema.items.some((item) => hasOptionalProperties(item))) {
|
||||
return true
|
||||
}
|
||||
} else if (hasOptionalProperties(schema.items)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schema.additionalProperties &&
|
||||
typeof schema.additionalProperties === 'object' &&
|
||||
hasOptionalProperties(schema.additionalProperties)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
||||
if (
|
||||
Array.isArray(schema[key]) &&
|
||||
schema[key].some((subSchema: Record<string, any>) => hasOptionalProperties(subSchema))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const searchHubScriptsSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
|
||||
@@ -492,7 +492,7 @@
|
||||
}
|
||||
|
||||
const skipSelector =
|
||||
'[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu]'
|
||||
'[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]'
|
||||
if (target) {
|
||||
const tag = target.tagName
|
||||
const isEditable =
|
||||
|
||||
Reference in New Issue
Block a user