diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 51d0b6b1bfc1b..75489ecfaada3 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -247,7 +247,6 @@ {availableContext} {disabled} isFirstMessage={messages.length === 0} - placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'} />
{ + if (!isFirstMessage) { + return 'Ask followup' + } + if (placeholder) { - // If a custom placeholder is provided, use it return placeholder } - // Generate placeholder based on current AI mode switch (aiChatManager.mode) { case AIMode.SCRIPT: - return 'Modify this script, fix errors, or generate new code...' + return 'Modify this script...' case AIMode.FLOW: - return 'Edit this flow, add steps, or modify workflow logic...' + return 'Modify this flow...' case AIMode.NAVIGATOR: - return 'Help me navigate Windmill or find features...' + return 'Navigate the app...' case AIMode.API: - return 'Make API calls to fetch data or manage resources...' + return 'Make API calls...' case AIMode.ASK: - return 'Ask questions about Windmill features and documentation...' + return 'Ask questions about Windmill...' default: return 'Ask anything' } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 67dcd2e8f8fda..3792bd1870cbd 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -8,7 +8,15 @@ import { } from './flow/core' import ContextManager from './ContextManager.svelte' import HistoryManager from './HistoryManager.svelte' -import { extractCodeFromMarkdown, getLatestAssistantMessage, processToolCall, type DisplayMessage, type Tool, type ToolCallbacks, type ToolDisplayMessage } from './shared' +import { + extractCodeFromMarkdown, + getLatestAssistantMessage, + processToolCall, + type DisplayMessage, + type Tool, + type ToolCallbacks, + type ToolDisplayMessage +} from './shared' import type { ChatCompletionChunk, ChatCompletionMessageParam, @@ -80,7 +88,9 @@ class AIChatManager { helpers = $state(undefined) scriptEditorOptions = $state(undefined) - scriptEditorApplyCode = $state<((code: string, applyAll?: boolean) => void) | undefined>(undefined) + scriptEditorApplyCode = $state<((code: string, applyAll?: boolean) => void) | undefined>( + undefined + ) scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined) flowAiChatHelpers = $state(undefined) pendingNewCode = $state(undefined) @@ -660,7 +670,10 @@ class AIChatManager { } switch (this.mode) { case AIMode.FLOW: - userMessage = prepareFlowUserMessage(oldInstructions, this.flowAiChatHelpers!.getFlowAndSelectedId()) + userMessage = prepareFlowUserMessage( + oldInstructions, + this.flowAiChatHelpers!.getFlowAndSelectedId() + ) break case AIMode.NAVIGATOR: userMessage = prepareNavigatorUserMessage(oldInstructions) @@ -733,8 +746,8 @@ class AIChatManager { } else { // Create new tool message with metadata const newMessage: ToolDisplayMessage = { - role: 'tool', - tool_call_id: id, + role: 'tool', + tool_call_id: id, content: metadata?.content ?? metadata?.error ?? '', ...(metadata || {}) } diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 9c2ff2af27125..c9f8867c2a7e0 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -12,10 +12,8 @@ import { getLangContext, SUPPORTED_CHAT_SCRIPT_LANGUAGES } from '../script/core' -import { createSearchHubScriptsTool, createToolDef, type Tool, executeTestRun } from '../shared' +import { createSearchHubScriptsTool, createToolDef, type Tool, executeTestRun, buildSchemaForTool, buildTestRunArgs } from '../shared' import type { ExtendedOpenFlow } from '$lib/components/flows/types' -import { copilotSessionModel } from '$lib/stores' -import { get } from 'svelte/store' export type AIModuleAction = 'added' | 'modified' | 'removed' @@ -351,6 +349,21 @@ const testRunFlowToolDef = createToolDef( 'Execute a test run of the current flow' ) +const testRunStepSchema = z.object({ + stepId: z.string().describe('The id of the step to test'), + args: z + .object({}) + .nullable() + .optional() + .describe('Arguments to pass to the step (optional, uses default step inputs if not provided)') +}) + +const testRunStepToolDef = createToolDef( + testRunStepSchema, + 'test_run_step', + 'Execute a test run of a specific step in the flow' +) + const workspaceScriptsSearch = new WorkspaceScriptsSearch() export const flowTools: Tool[] = [ @@ -364,7 +377,8 @@ export const flowTools: Tool[] = [ const parsedArgs = searchScriptsSchema.parse(args) const scriptResults = await workspaceScriptsSearch.search(parsedArgs.query, workspace) toolCallbacks.setToolStatus(toolId, { - content: 'Found ' + + content: + 'Found ' + scriptResults.length + ' scripts in the workspace related to "' + args.query + @@ -378,19 +392,20 @@ export const flowTools: Tool[] = [ fn: async ({ args, helpers, toolId, toolCallbacks }) => { const parsedArgs = addStepSchema.parse(args) toolCallbacks.setToolStatus(toolId, { - content: parsedArgs.location.type === 'after' - ? `Adding a step after step '${parsedArgs.location.afterId}'` - : parsedArgs.location.type === 'start' - ? 'Adding a step at the start' - : parsedArgs.location.type === 'start_inside_forloop' - ? `Adding a step at the start of the forloop step '${parsedArgs.location.inside}'` - : parsedArgs.location.type === 'start_inside_branch' - ? `Adding a step at the start of the branch ${parsedArgs.location.branchIndex + 1} of step '${parsedArgs.location.inside}'` - : parsedArgs.location.type === 'preprocessor' - ? 'Adding a preprocessor step' - : parsedArgs.location.type === 'failure' - ? 'Adding a failure step' - : 'Adding a step' + content: + parsedArgs.location.type === 'after' + ? `Adding a step after step '${parsedArgs.location.afterId}'` + : parsedArgs.location.type === 'start' + ? 'Adding a step at the start' + : parsedArgs.location.type === 'start_inside_forloop' + ? `Adding a step at the start of the forloop step '${parsedArgs.location.inside}'` + : parsedArgs.location.type === 'start_inside_branch' + ? `Adding a step at the start of the branch ${parsedArgs.location.branchIndex + 1} of step '${parsedArgs.location.inside}'` + : parsedArgs.location.type === 'preprocessor' + ? 'Adding a preprocessor step' + : parsedArgs.location.type === 'failure' + ? 'Adding a failure step' + : 'Adding a step' }) const id = await helpers.insertStep(parsedArgs.location, parsedArgs.step) helpers.selectStep(id) @@ -469,7 +484,9 @@ export const flowTools: Tool[] = [ def: setCodeToolDef, fn: async ({ args, helpers, toolId, toolCallbacks }) => { const parsedArgs = setCodeSchema.parse(args) - toolCallbacks.setToolStatus(toolId, { content: `Setting code for step '${parsedArgs.id}'...` }) + toolCallbacks.setToolStatus(toolId, { + content: `Setting code for step '${parsedArgs.id}'...` + }) await helpers.setCode(parsedArgs.id, parsedArgs.code) helpers.selectStep(parsedArgs.id) toolCallbacks.setToolStatus(toolId, { content: `Set code for step '${parsedArgs.id}'` }) @@ -519,7 +536,9 @@ export const flowTools: Tool[] = [ const parsedArgs = setForLoopIteratorExpressionSchema.parse(args) await helpers.setForLoopIteratorExpression(parsedArgs.id, parsedArgs.expression) helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Set forloop '${parsedArgs.id}' iterator expression` }) + toolCallbacks.setToolStatus(toolId, { + content: `Set forloop '${parsedArgs.id}' iterator expression` + }) return `Forloop '${parsedArgs.id}' iterator expression set` } }, @@ -535,28 +554,33 @@ export const flowTools: Tool[] = [ parsedArgs.query, workspace ) - toolCallbacks.setToolStatus(toolId, { content: 'Retrieved resource types for "' + parsedArgs.query + '"' }) + toolCallbacks.setToolStatus(toolId, { + content: 'Retrieved resource types for "' + parsedArgs.query + '"' + }) return formattedResourceTypes } }, { def: testRunFlowToolDef, - fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => { + fn: async function({ args, workspace, helpers, toolCallbacks, toolId }) { const { flow } = helpers.getFlowAndSelectedId() if (!flow || !flow.value) { - toolCallbacks.setToolStatus(toolId, { + toolCallbacks.setToolStatus(toolId, { content: 'No flow available to test', error: 'No flow found in current context' }) - throw new Error('No flow available to test. Please ensure you have a flow open in the editor.') + throw new Error( + 'No flow available to test. Please ensure you have a flow open in the editor.' + ) } + const parsedArgs = await buildTestRunArgs(args, this.def) return executeTestRun({ jobStarter: () => JobService.runFlowPreview({ workspace: workspace, requestBody: { - args: args, + args: parsedArgs, value: flow.value, } }), @@ -568,25 +592,120 @@ export const flowTools: Tool[] = [ }) }, setSchema: async function(helpers: FlowAIChatHelpers) { - try { - if (this.def?.function?.parameters) { - const flowInputsSchema = await helpers.getFlowInputsSchema() - this.def.function.parameters = { ...flowInputsSchema, additionalProperties: false } - // OPEN AI models don't support strict mode well with schema with complex properties, so we disable it - const model = get(copilotSessionModel)?.provider - if (model === 'openai' || model === 'azure_openai') { - this.def.function.strict = false - } - } - } catch (e) { - console.error('Error setting schema for test_run_flow tool', e) - // fallback to schema with any properties - this.def.function.parameters = { - type: 'object', - properties: {}, - additionalProperties: true, - strict: false - } + await buildSchemaForTool(this.def, async () => { + const flowInputsSchema = await helpers.getFlowInputsSchema() + return flowInputsSchema + }) + }, + requiresConfirmation: true, + showDetails: true + }, + { + // set strict to false to avoid issues with open ai models + def: { ...testRunStepToolDef, function: { ...testRunStepToolDef.function, strict: false } }, + fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => { + const { flow } = helpers.getFlowAndSelectedId() + + if (!flow || !flow.value) { + toolCallbacks.setToolStatus(toolId, { + content: 'No flow available to test step from', + error: 'No flow found in current context' + }) + throw new Error( + 'No flow available to test step from. Please ensure you have a flow open in the editor.' + ) + } + + const stepId = args.stepId + const stepArgs = args.args || {} + + // Find the step in the flow + const modules = helpers.getModules() + let targetModule: FlowModule | undefined = modules.find((m) => m.id === stepId) + + if (!targetModule) { + toolCallbacks.setToolStatus(toolId, { + content: `Step '${stepId}' not found in flow`, + error: `Step with id '${stepId}' does not exist in the current flow` + }) + throw new Error( + `Step with id '${stepId}' not found in flow. Available steps: ${modules.map((m) => m.id).join(', ')}` + ) + } + + const module = targetModule + const moduleValue = module.value + + if (moduleValue.type === 'rawscript') { + // Test raw script step + + return executeTestRun({ + jobStarter: () => + JobService.runScriptPreview({ + workspace: workspace, + requestBody: { + content: moduleValue.content ?? '', + language: moduleValue.language, + args: module.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs } : stepArgs + } + }), + workspace, + toolCallbacks, + toolId, + startMessage: `Starting test run of step '${stepId}'...`, + contextName: 'script' + }) + } else if (moduleValue.type === 'script') { + // Test script step - need to get the script content + const script = moduleValue.hash + ? await ScriptService.getScriptByHash({ + workspace: workspace, + hash: moduleValue.hash + }) + : await ScriptService.getScriptByPath({ + workspace: workspace, + path: moduleValue.path + }) + + return executeTestRun({ + jobStarter: () => + JobService.runScriptPreview({ + workspace: workspace, + requestBody: { + content: script.content, + language: script.language, + args: module.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs } : stepArgs, + } + }), + workspace, + toolCallbacks, + toolId, + startMessage: `Starting test run of script step '${stepId}'...`, + contextName: 'script' + }) + } else if (moduleValue.type === 'flow') { + // Test flow step + return executeTestRun({ + jobStarter: () => + JobService.runFlowByPath({ + workspace: workspace, + path: moduleValue.path, + requestBody: stepArgs + }), + workspace, + toolCallbacks, + toolId, + startMessage: `Starting test run of flow step '${stepId}'...`, + contextName: 'flow' + }) + } else { + toolCallbacks.setToolStatus(toolId, { + content: `Step type '${moduleValue.type}' not supported for testing`, + error: `Cannot test step of type '${moduleValue.type}'` + }) + throw new Error( + `Cannot test step of type '${moduleValue.type}'. Supported types: rawscript, script, flow` + ) } }, requiresConfirmation: true, @@ -599,7 +718,7 @@ export function prepareFlowSystemMessage(): ChatCompletionSystemMessageParam { Follow the user instructions carefully. Go step by step, and explain what you're doing as you're doing it. DO NOT wait for user confirmation before performing an action. Only do it if the user explicitly asks you to wait in their initial instructions. -ALWAYS use the \`test_run_flow\` tool to test the flow, and iterate on the flow until it works as expected. If the user cancels the test run, do not try again and wait for the next user instruction. +ALWAYS test your modifications. You have access to the \`test_run_flow\` and \`test_run_step\` tools to test the flow and steps. If you only modified a single step, use the \`test_run_step\` tool to test it. If you modified the flow, use the \`test_run_flow\` tool to test it. If the user cancels the test run, do not try again and wait for the next user instruction. ## Understanding User Requests diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 7b938528033b4..372debb49f632 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -13,7 +13,7 @@ import { scriptLangToEditorLang } from '$lib/scripts' import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/utils' import type { CodePieceElement, ContextElement } from '../context' import { PYTHON_PREPROCESSOR_MODULE_CODE, TS_PREPROCESSOR_MODULE_CODE } from '$lib/script_helpers' -import { createSearchHubScriptsTool, type Tool, executeTestRun } from '../shared' +import { createSearchHubScriptsTool, type Tool, executeTestRun, buildSchemaForTool, buildTestRunArgs } from '../shared' import { setupTypeAcquisition, type DepsToGet } from '$lib/ata' import { getModelContextWindow } from '../../lib' import { inferArgs } from '$lib/infer' @@ -855,7 +855,7 @@ const TEST_RUN_SCRIPT_TOOL: ChatCompletionTool = { export const testRunScriptTool: Tool = { def: TEST_RUN_SCRIPT_TOOL, - fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => { + fn: async function({ args, workspace, helpers, toolCallbacks, toolId }) { const scriptOptions = helpers.getScriptOptions() if (!scriptOptions) { @@ -880,17 +880,16 @@ export const testRunScriptTool: Tool = { toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied, starting test...' }) } + const parsedArgs = await buildTestRunArgs(args, this.def) + return executeTestRun({ jobStarter: () => JobService.runScriptPreview({ workspace: workspace, requestBody: { path: scriptOptions.path, content: codeToTest, - args, + args: parsedArgs, language: scriptOptions.lang as ScriptLang, - tag: undefined, - lock: undefined, - script_hash: undefined } }), workspace, @@ -901,7 +900,7 @@ export const testRunScriptTool: Tool = { }) }, setSchema: async function(helpers: ScriptChatHelpers) { - try { + await buildSchemaForTool(this.def, async () => { const scriptOptions = helpers.getScriptOptions() const code = scriptOptions?.code const lang = scriptOptions?.lang @@ -911,23 +910,10 @@ export const testRunScriptTool: Tool = { if (codeToTest) { const newSchema = emptySchema() await inferArgs(lang, codeToTest, newSchema) - this.def.function.parameters = { ...newSchema, additionalProperties: false } - // OPEN AI models don't support strict mode well with schema with complex properties, so we disable it - const model = get(copilotSessionModel)?.provider - if (model === 'openai' || model === 'azure_openai') { - this.def.function.strict = false - } + return newSchema } - } catch (e) { - console.error('Error setting schema for test_run_script tool', e) - // fallback to schema with any properties - this.def.function.parameters = { - type: 'object', - properties: {}, - additionalProperties: true, - strict: false - } - } + return emptySchema() + }) }, requiresConfirmation: true, showDetails: true, diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 0503f3a753469..023b539d0b2a6 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -5,7 +5,7 @@ import type { } from 'openai/resources/chat/completions.mjs' import { get } from 'svelte/store' import type { ContextElement } from './context' -import { workspaceStore } from '$lib/stores' +import { copilotSessionModel, workspaceStore } from '$lib/stores' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import type { FunctionParameters } from 'openai/resources/shared.mjs' import { zodToJsonSchema } from 'zod-to-json-schema' @@ -253,6 +253,32 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ } }) +export async function buildSchemaForTool(toolDef: ChatCompletionTool, schemaBuilder: () => Promise): Promise { + try { + const schema = await schemaBuilder() + + // if schema properties contains values different from '^[a-zA-Z0-9_.-]{1,64}$' + const invalidProperties = Object.keys(schema.properties ?? {}).filter((key) => !/^[a-zA-Z0-9_.-]{1,64}$/.test(key)) + if (invalidProperties.length > 0) { + console.warn(`Invalid flow inputs schema: ${invalidProperties.join(', ')}`) + throw new Error(`Invalid flow inputs schema: ${invalidProperties.join(', ')}`) + } + + toolDef.function.parameters = { ...schema, additionalProperties: false } + // OPEN AI models don't support strict mode well with schema with complex properties, so we disable it + const model = get(copilotSessionModel)?.provider + if (model === 'openai' || model === 'azure_openai') { + toolDef.function.strict = false + } + return true + } catch (error) { + console.error('Error building schema for tool', error) + // fallback to schema with args as a JSON string + toolDef.function.parameters = { type: 'object', properties: { args: { type: 'string', description: 'JSON string containing the arguments for the tool' } }, additionalProperties: false, strict: false, required: ['args'] } + return false + } +} + // Constants for result formatting const MAX_RESULT_LENGTH = 12000 const MAX_LOG_LENGTH = 4000 @@ -362,6 +388,20 @@ function getErrorMessage(result: unknown): string { return 'Unknown error' } +// Build test run args based on the tool definition, if it contains a fallback schema +export async function buildTestRunArgs(args: any, toolDef: ChatCompletionTool): Promise { + let parsedArgs = args + // if the schema is the fallback schema, parse the args as a JSON string + if ((toolDef.function.parameters as any).properties?.args?.description === 'JSON string containing the arguments for the tool') { + try { + parsedArgs = JSON.parse(args.args) + } catch (error) { + console.error('Error parsing arguments for tool', error) + } + } + return parsedArgs +} + // Main execution function for test runs export async function executeTestRun(config: TestRunConfig): Promise { try {