fix: pipeline tools callable right after open_preview; write_script refuses pipeline nodes in sessions

This commit is contained in:
Diego Imbert
2026-10-01 13:34:59 +02:00
parent 55fe18b09a
commit 2d50f1f9b0
8 changed files with 91 additions and 12 deletions
@@ -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) ?? ''
+4 -1
View File
@@ -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.`