From d243e0cde899b6d34ee6d54be76bd46f08c4b64a Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 13 May 2026 14:38:49 +0200 Subject: [PATCH] align global flow tool arguments (#9146) --- ai_evals/cases/global.yaml | 34 +++++ .../copilot/chat/global/core.test.ts | 77 ++++++++-- .../components/copilot/chat/global/core.ts | 144 +++++++++++------- 3 files changed, 191 insertions(+), 64 deletions(-) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 709081942b..b4526f8a85 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -53,3 +53,37 @@ - uppercases the provided name in the greeting - returns a message ending with an exclamation mark - does not deploy or save the draft to the workspace + +- id: global-test3-flow-create + prompt: |- + Create a draft flow at `f/evals/global/sum_numbers`. + It should take two numeric inputs, `a` and `b`, and return their sum. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/sum_numbers + valueIncludes: + - modules + - rawscript + - flow_input.a + - flow_input.b + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_flow + field: modules + stringStartsWithAnyOf: + - "[" + judgeChecklist: + - creates a flow draft at f/evals/global/sum_numbers + - the flow accepts numeric inputs a and b + - the flow returns the sum of a and b + - the result stays as an AI draft and is not deployed or saved to the workspace diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 2664c1495d..2732db4dac 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -113,19 +113,17 @@ describe('global AI tools', () => { await callGlobalTool('write_flow', { path: 'f/flows/empty-module', summary: 'Flow with empty module', - value: JSON.stringify({ - modules: [ - { - id: 'empty_step', - value: { - type: 'rawscript', - language: 'bun', - content: '', - input_transforms: {} - } + modules: JSON.stringify([ + { + id: 'empty_step', + value: { + type: 'rawscript', + language: 'bun', + content: '', + input_transforms: {} } - ] - }) + } + ]) }) const code = 'export async function main() {\n\treturn 42\n}' @@ -145,4 +143,59 @@ describe('global AI tools', () => { }) ).resolves.toBe(code) }) + + it('writes flows with flow-mode arguments and reads compact flow value', async () => { + const writeResult = JSON.parse( + await callGlobalTool('write_flow', { + path: 'f/flows/with-schema-and-groups', + summary: 'Flow with schema and groups', + modules: JSON.stringify([ + { + id: 'start', + summary: 'Start', + value: { + type: 'identity' + } + } + ]), + schema: JSON.stringify({ + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }), + groups: JSON.stringify([{ summary: 'Main', start_id: 'start', end_id: 'start' }]) + }) + ) + + expect(writeResult.item.value.value).toBeUndefined() + + const raw = await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/with-schema-and-groups' + }) + const item = JSON.parse(raw) + + expect(item.value).toMatchObject({ + modules: [ + { + id: 'start', + summary: 'Start', + value: { type: 'identity' } + } + ], + schema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }, + preprocessor_module: null, + failure_module: null, + groups: [{ summary: 'Main', start_id: 'start', end_id: 'start' }] + }) + expect(item.value.value).toBeUndefined() + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index de18426a3c..210636e43d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -37,6 +37,7 @@ import { import { applyEditableFlowJsonToFlow, buildEditableFlowJson, + type EditableFlowJson, validateEditableFlowJson } from '../flow/editableFlowJson' import { createInlineScriptSession } from '../flow/inlineScriptsUtils' @@ -59,7 +60,6 @@ import { type ToolCallbacks, type ToolDisplayAction } from '../shared' -import { flowModuleSchema, flowModulesSchema } from '../flow/openFlowZod.gen' import { resourceRequestSchema, scheduleRequestSchema, @@ -149,22 +149,6 @@ const writeScriptSchema = z.object({ content: z.string().describe('Full script source code.') }) -const flowValueSchema = z - .looseObject({ - modules: flowModulesSchema.describe('Sequential flow modules.'), - preprocessor_module: flowModuleSchema - .nullable() - .optional() - .describe( - "Optional preprocessor module with id 'preprocessor'. Runs before normal modules; cannot reference results.*." - ), - failure_module: flowModuleSchema - .nullable() - .optional() - .describe("Optional failure handler module with id 'failure'.") - }) - .describe('OpenFlow value: modules plus optional preprocessor_module and failure_module.') - const readFlowModuleCodeSchema = z.object({ path: z.string().describe('Workspace path of the flow.'), module_id: z @@ -184,24 +168,81 @@ const setFlowModuleCodeSchema = z.object({ code: z.string().describe('New script source. Replaces the module\'s value.content entirely.') }) -// `value` is taken as a JSON string rather than a typed object because the -// underlying flowValueSchema is recursive (modules can contain modules), which -// makes z.toJSONSchema emit $defs/$ref. Gemini's tools API rejects those -// keywords ("Unknown name $ref/$defs"). The string is parsed and validated -// against flowValueSchema inside the handler. Same trick as set_flow_json in -// chat/flow/core.ts (see comment on its schema). +// Flow structure fields are taken as JSON strings rather than typed objects +// because the underlying flow module schema is recursive (modules can contain +// modules), which makes z.toJSONSchema emit $defs/$ref. Gemini's tools API +// 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.'), summary: z.string().optional().describe('Short human-readable summary.'), - value: z + modules: z.string().describe('JSON string containing the complete flow modules array.'), + schema: z .string() + .optional() + .nullable() + .describe('JSON string containing the flow input schema.'), + preprocessor_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional preprocessor module.'), + failure_module: z + .string() + .optional() + .nullable() + .describe('JSON string containing the optional failure module.'), + groups: z + .string() + .optional() + .nullable() .describe( - 'JSON string of the OpenFlow value object: { modules, preprocessor_module?, failure_module? }. Pass it as a JSON-encoded string, not a nested object.' + 'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.' ) }) +function parseOptionalJsonArg(value: unknown, field: string): unknown { + if (value === undefined || value === null) { + return value + } + + try { + return typeof value === 'string' ? JSON.parse(value) : value + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid JSON for ${field}: ${message}`) + } +} + +function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue { + const value: FlowValue = { + modules: editable.modules, + preprocessor_module: editable.preprocessor_module ?? undefined, + failure_module: editable.failure_module ?? undefined, + groups: editable.groups ?? undefined + } + return { + value, + schema: editable.schema, + groups: editable.groups + } +} + +function flowDraftAsEditableInput(flowDraft: FlowDraftValue): { + value: FlowValue + schema?: Record | null | undefined +} { + return { + value: + flowDraft.groups === undefined + ? flowDraft.value + : { ...flowDraft.value, groups: flowDraft.groups ?? undefined }, + schema: flowDraft.schema + } +} + const writeScheduleSchema = scheduleRequestSchema const writeTriggerSchema = z.object({ @@ -428,7 +469,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. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, value is { value: , schema, groups } so the inputs schema and groups round-trip through deploy. 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. +- 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//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. - Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts. @@ -496,7 +537,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { if (item.type !== 'flow' || !item.value) return item const flowDraft = item.value as FlowDraftValue const session = createInlineScriptSession() - const editable = buildEditableFlowJson(flowDraft, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(flowDraft), session) return { type: 'flow', path: item.path, @@ -1074,8 +1115,9 @@ function getFlowInstructions(): string { return `# Global draft flow instructions - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. -- A flow draft is a workspace item: \`{ type: 'flow', path, summary?, value, isDraft }\` where \`value\` is \`{ value: , schema, groups }\`. The inputs schema and groups are kept alongside the OpenFlow value so deploy round-trips them. -- \`value.modules\` contains normal sequential modules. Use top-level \`value.preprocessor_module\` and \`value.failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`value.modules\`. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions. +- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. +- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. - Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. - When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first. @@ -1087,7 +1129,7 @@ function getFlowInstructions(): string { - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the AI draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. -- \`write_flow\` is for full overwrites / create-from-scratch. Its \`value\` argument is the **non-compact** OpenFlow value (rawscript content is the actual code, not a placeholder). +- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). # Windmill flow authoring reference @@ -1271,35 +1313,29 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeFlowSchema, 'write_flow', - 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. value must be a JSON-encoded string of the OpenFlow value object.' + 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. Uses the same flow-structure arguments as set_flow_json plus path and summary.' ), showDetails: true, streamArguments: true, showFade: true, fn: async (ctx) => { const parsed = writeFlowSchema.parse(ctx.args) - let value: unknown - try { - value = JSON.parse(parsed.value) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Invalid JSON for value: ${message}`) - } - const validated = flowValueSchema.safeParse(value) - if (!validated.success) { - throw new Error( - `Invalid flow value: ${validated.error.issues - .slice(0, 5) - .map((i) => `${i.path.join('.')}: ${i.message}`) - .join('; ')}` - ) - } + const editable = validateEditableFlowJson({ + modules: parseOptionalJsonArg(parsed.modules, 'modules'), + schema: parseOptionalJsonArg(parsed.schema, 'schema'), + preprocessor_module: parseOptionalJsonArg( + parsed.preprocessor_module, + 'preprocessor_module' + ), + failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), + groups: parseOptionalJsonArg(parsed.groups, 'groups') + }) return writeDraft( { type: 'flow', path: parsed.path, summary: parsed.summary, - value: { value: validated.data as FlowValue, schema: null, groups: null }, + value: editableFlowToDraftValue(editable), isDraft: true }, ctx @@ -1683,7 +1719,7 @@ async function patchFlowJson( // model uses set_flow_module_code to change inline script bodies. const base = await loadFlowDraftValue(path, ctx.workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const currentJson = JSON.stringify(editable) const updatedJson = findAndReplace( currentJson, @@ -1730,7 +1766,7 @@ async function readFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - buildEditableFlowJson(base.flow, session) + buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) const content = session.get(args.module_id) if (content === undefined) { throw new Error( @@ -1753,7 +1789,7 @@ async function setFlowModuleCode( }) const base = await loadFlowDraftValue(args.path, workspace) const session = createInlineScriptSession() - const editable = buildEditableFlowJson(base.flow, session) + const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) if (!session.has(args.module_id)) { throw new Error( `Module "${args.module_id}" is not an inline rawscript in flow "${args.path}". Use patch_flow_json or write_flow for structural changes.` @@ -2342,6 +2378,10 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise