mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
fix: pipeline tools callable right after open_preview; write_script refuses pipeline nodes in sessions
This commit is contained in:
@@ -19,6 +19,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry'
|
||||
import {
|
||||
appendPendingToolImages,
|
||||
processToolCall,
|
||||
type LiveToolSet,
|
||||
queuedToolStatus,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
@@ -197,7 +198,11 @@ export async function parseAnthropicCompletion(
|
||||
tools: Tool<any>[],
|
||||
helpers: any,
|
||||
abortController?: AbortController,
|
||||
options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void }
|
||||
options?: {
|
||||
workspace?: string
|
||||
onTokenUsage?: (usage: ChatTokenUsage) => void
|
||||
live?: LiveToolSet
|
||||
}
|
||||
): Promise<ParsedCompletionResult> {
|
||||
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
|
||||
let error = null
|
||||
@@ -447,7 +452,8 @@ export async function parseAnthropicCompletion(
|
||||
helpers,
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace,
|
||||
messages
|
||||
messages,
|
||||
live: options?.live
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
|
||||
@@ -453,7 +453,8 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
const parseOptions = {
|
||||
workspace,
|
||||
provider: modelProvider.provider,
|
||||
onTokenUsage: reportUsage
|
||||
onTokenUsage: reportUsage,
|
||||
live: () => ({ tools: config.tools, helpers: config.helpers })
|
||||
}
|
||||
|
||||
if (isOpenAI) {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
stripFileArgs
|
||||
} from '$lib/components/job_args'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations'
|
||||
import { PLAN_MODE_MESSAGES } from '../planModeMessages'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
@@ -1471,7 +1472,7 @@ ${pipelineBullet}`
|
||||
canWriteDraft,
|
||||
`
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it adds build_pipeline_node / edit_pipeline_node to your tools from the next step on — they are not in your tool list before open_preview returns, so call open_preview on its own, wait for its result, then build. Use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
|
||||
)}
|
||||
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
|
||||
- Writing an app file does not compile it: the open preview rebuilds it afterwards. After editing a raw app's frontend files with its preview open, call get_app_runtime_logs to check the build — when it failed, it returns the build errors (e.g. syntax or import errors) and bundler logs to fix.
|
||||
@@ -4254,7 +4255,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Found ${results.length} resource type(s) for "${parsed.query}"`
|
||||
})
|
||||
return JSON.stringify(
|
||||
const listing = JSON.stringify(
|
||||
results.map((rt) => ({
|
||||
name: rt.name,
|
||||
schema: rt.schema
|
||||
@@ -4262,6 +4263,15 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
null,
|
||||
2
|
||||
)
|
||||
// The search is semantic and always returns its closest types, so a miss reads
|
||||
// like a hit unless the absence is spelled out.
|
||||
const words = (parsed.query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(
|
||||
(w) => w.length >= 3
|
||||
)
|
||||
const named = results.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w)))
|
||||
return named || words.length === 0
|
||||
? listing
|
||||
: `${listing}\n\nNo resource type name contains "${parsed.query}": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.`
|
||||
}
|
||||
},
|
||||
createDbSchemaTool<{}>({
|
||||
@@ -5355,6 +5365,17 @@ function writeScriptDraft(
|
||||
ctx: WriteDraftCtx,
|
||||
codeDiff?: ToolCodeDiff
|
||||
): Promise<string> {
|
||||
// A session builds pipeline nodes into the folder's pipeline draft; a script draft
|
||||
// written here never reaches the pipeline graph, and saving the pipeline drops it.
|
||||
if (
|
||||
(ctx.sessionId ?? sessionIdFromCtx(ctx)) &&
|
||||
parsePipelineAnnotations(args.content).inPipeline
|
||||
) {
|
||||
const folder = args.path.match(/^f\/([^/]+)\//)?.[1] ?? '<folder>'
|
||||
throw new Error(
|
||||
`"${args.path}" is a data pipeline node (its source starts with the pipeline annotation). In a session, pipeline nodes are not script drafts: call open_preview(kind="pipeline", path="${folder}") if that folder's pipeline editor is not open yet, then use build_pipeline_node (new node) or edit_pipeline_node (existing node). Nothing was written.`
|
||||
)
|
||||
}
|
||||
return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, {
|
||||
override: args.override,
|
||||
codeDiff:
|
||||
|
||||
@@ -16,6 +16,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry'
|
||||
import {
|
||||
appendPendingToolImages,
|
||||
processToolCall,
|
||||
type LiveToolSet,
|
||||
queuedToolStatus,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
@@ -395,7 +396,11 @@ export async function parseOpenAIResponsesCompletion(
|
||||
addedMessages: ChatCompletionMessageParam[],
|
||||
tools: Tool<any>[],
|
||||
helpers: any,
|
||||
options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void }
|
||||
options?: {
|
||||
workspace?: string
|
||||
onTokenUsage?: (usage: ChatTokenUsage) => void
|
||||
live?: LiveToolSet
|
||||
}
|
||||
): Promise<ParsedCompletionResult> {
|
||||
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
|
||||
let error: OpenAIError | ResponseErrorEvent | null = null
|
||||
@@ -602,7 +607,8 @@ export async function parseOpenAIResponsesCompletion(
|
||||
helpers,
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace,
|
||||
messages
|
||||
messages,
|
||||
live: options?.live
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
|
||||
@@ -394,6 +394,30 @@ describe('processToolCall', () => {
|
||||
expect(result.content).toBe(`Error while calling tool: ${expectedError}`)
|
||||
})
|
||||
|
||||
it('resolves a tool registered earlier in the same batch from the live set', async () => {
|
||||
const { createToolDef, processToolCall } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('built')
|
||||
const registered = {
|
||||
def: createToolDef(z.object({}), 'build_pipeline_node', 'Build node'),
|
||||
fn
|
||||
}
|
||||
const result = await processToolCall({
|
||||
tools: [],
|
||||
live: () => ({ tools: [registered], helpers: { pipelines: true } }),
|
||||
toolCall: {
|
||||
id: 'call_live',
|
||||
type: 'function',
|
||||
function: { name: 'build_pipeline_node', arguments: '{}' }
|
||||
},
|
||||
helpers: {},
|
||||
workspace: 'test-workspace',
|
||||
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
|
||||
})
|
||||
|
||||
expect(result.content).toBe('built')
|
||||
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ helpers: { pipelines: true } }))
|
||||
})
|
||||
|
||||
it('continues to confirmation when pre-confirmation validation passes', async () => {
|
||||
const { createToolDef, processToolCall } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ok')
|
||||
|
||||
@@ -897,6 +897,13 @@ async function callTool<T>({
|
||||
}): Promise<string> {
|
||||
const tool = tools.find((t) => t.def.function.name === functionName)
|
||||
if (!tool) {
|
||||
// The pipeline tools exist only while a pipeline editor is mounted, and change_mode
|
||||
// cannot mount one, so the generic hint would send the model probing.
|
||||
if (/_pipeline_node$|^get_pipeline_graph$/.test(functionName)) {
|
||||
throw new Error(
|
||||
`${functionName} is not available yet: no pipeline editor is open for this chat. Call open_preview(kind="pipeline", path="<folder>") first and wait for its result; once it reports the editor open, the pipeline tools are callable. Do not fall back to write_script.`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.`
|
||||
)
|
||||
@@ -987,22 +994,33 @@ type ToolCallStatus =
|
||||
| 'blocked_plan_mode'
|
||||
| 'held_for_instructions'
|
||||
|
||||
/** Re-reads the chat loop's current tools and helpers. A call earlier in the same
|
||||
* response can register tools (open_preview mounting the pipeline editor, change_mode),
|
||||
* so a later call in that batch must resolve against the live set, not the snapshot the
|
||||
* response was requested with. */
|
||||
export type LiveToolSet = () => { tools: Tool<any>[]; helpers: any }
|
||||
|
||||
export async function processToolCall<T>({
|
||||
tools,
|
||||
tools: snapshotTools,
|
||||
toolCall,
|
||||
helpers,
|
||||
helpers: snapshotHelpers,
|
||||
toolCallbacks,
|
||||
workspace,
|
||||
messages = []
|
||||
messages = [],
|
||||
live
|
||||
}: {
|
||||
tools: Tool<T>[]
|
||||
toolCall: ChatCompletionMessageFunctionToolCall
|
||||
helpers: T
|
||||
live?: LiveToolSet
|
||||
toolCallbacks: ToolCallbacks
|
||||
workspace?: string
|
||||
/** The conversation so far, for which folder instructions it already carries. */
|
||||
messages?: readonly ChatCompletionMessageParam[]
|
||||
}): Promise<ChatCompletionMessageParam> {
|
||||
const current = live?.()
|
||||
const tools: Tool<T>[] = current?.tools ?? snapshotTools
|
||||
const helpers: T = current ? current.helpers : snapshotHelpers
|
||||
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
|
||||
const workspaceId = workspace ?? get(workspaceStore) ?? ''
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { formatResourceTypes } from './utils'
|
||||
import {
|
||||
appendPendingToolImages,
|
||||
processToolCall,
|
||||
type LiveToolSet,
|
||||
queuedToolStatus,
|
||||
type Tool,
|
||||
type ToolCallbacks
|
||||
@@ -1223,6 +1224,7 @@ export async function parseOpenAICompletion(
|
||||
workspace?: string
|
||||
provider?: string
|
||||
onTokenUsage?: (usage: ChatTokenUsage) => void
|
||||
live?: LiveToolSet
|
||||
}
|
||||
): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> {
|
||||
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
|
||||
@@ -1434,7 +1436,8 @@ export async function parseOpenAICompletion(
|
||||
helpers,
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace,
|
||||
messages
|
||||
messages,
|
||||
live: options?.live
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
|
||||
@@ -1303,7 +1303,7 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
|
||||
// off the owner path twice over) is rejected by build_pipeline_node.
|
||||
return `${
|
||||
result.status === 'focused' ? 'Focused the' : 'Opened the'
|
||||
} pipeline editor for folder "${folder}" in the side panel. Its nodes go at paths under \`f/${folder}/\` (e.g. \`f/${folder}/<node_name>\`).`
|
||||
} pipeline editor for folder "${folder}" in the side panel. build_pipeline_node, edit_pipeline_node and the other pipeline tools are now in your tool list: call them in your next step. Its nodes go at paths under \`f/${folder}/\` (e.g. \`f/${folder}/<node_name>\`).`
|
||||
}
|
||||
return result.status === 'focused'
|
||||
? `A preview tab is already showing ${kind} "${path}" — focused it.`
|
||||
|
||||
Reference in New Issue
Block a user