diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index ab69d0b1a3..ce11b0dade 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -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[], helpers: any, abortController?: AbortController, - options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } + options?: { + workspace?: string + onTokenUsage?: (usage: ChatTokenUsage) => void + live?: LiveToolSet + } ): Promise { 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) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index cde9d554cd..1e27becba0 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -453,7 +453,8 @@ export async function runChatLoop(config: ChatLoopConfig): Promise ({ tools: config.tools, helpers: config.helpers }) } if (isOpenAI) { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 03096b0334..8ffab2ee29 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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="") 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="") 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_ type.` } }, createDbSchemaTool<{}>({ @@ -5355,6 +5365,17 @@ function writeScriptDraft( ctx: WriteDraftCtx, codeDiff?: ToolCodeDiff ): Promise { + // 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] ?? '' + 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: diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 31d3b379ac..8ff1912ee4 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -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[], helpers: any, - options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void } + options?: { + workspace?: string + onTokenUsage?: (usage: ChatTokenUsage) => void + live?: LiveToolSet + } ): Promise { 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) diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 4574df7355..5a6c1f126c 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -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') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 6df8b68518..c9520af4a3 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -897,6 +897,13 @@ async function callTool({ }): Promise { 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="") 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[]; helpers: any } + export async function processToolCall({ - tools, + tools: snapshotTools, toolCall, - helpers, + helpers: snapshotHelpers, toolCallbacks, workspace, - messages = [] + messages = [], + live }: { tools: Tool[] 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 { + const current = live?.() + const tools: Tool[] = 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) ?? '' diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 655a872f96..efb91bd393 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -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 = {} @@ -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) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 766690ca7d..958414dcd1 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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}/\`).` + } 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}/\`).` } return result.status === 'focused' ? `A preview tab is already showing ${kind} "${path}" — focused it.`