mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
* fix: support aiagent steps in test_run_step Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: offer the agent's tools as a picker in the step run form Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: run a blank tool list as no tools when the step form is bypassed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2778 lines
96 KiB
TypeScript
2778 lines
96 KiB
TypeScript
import type {
|
||
ChatCompletionFunctionTool,
|
||
ChatCompletionMessageFunctionToolCall,
|
||
ChatCompletionMessageParam
|
||
} from 'openai/resources/chat/completions.mjs'
|
||
import type { UserDraftItemKind } from '$lib/gen'
|
||
// The gate's two refusals, from a module that holds prose and one size limit: under the
|
||
// shallow-import rule below, the rest of plan mode is not reachable from here.
|
||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||
import { NONE } from './sessionCapabilities'
|
||
// Import-free leaf, so it satisfies the shallow-import rule below.
|
||
import {
|
||
openItemPreviewAction,
|
||
type OpenItemPreviewAction,
|
||
type PreviewCardKind
|
||
} from './itemPreview'
|
||
|
||
// The tool modules that import this one (workspaceTools, flow/core, global/core, ...)
|
||
// call createToolDef and read SPECIAL_MODULE_IDS at *module scope*, so if a chunk cycle
|
||
// ever reaches this file they evaluate against uninitialized bindings and the app dies
|
||
// on load. Keep the import list here shallow. See docs/frontend-import-cycles.md.
|
||
|
||
/**
|
||
* Special module IDs used throughout the flow system
|
||
*/
|
||
export const SPECIAL_MODULE_IDS = {
|
||
/** The flow input schema node */
|
||
INPUT: 'Input',
|
||
/** The preprocessor module that runs before the flow */
|
||
PREPROCESSOR: 'preprocessor',
|
||
/** The failure handler module */
|
||
FAILURE: 'failure'
|
||
} as const
|
||
import { get } from 'svelte/store'
|
||
import type { PasteAttachment } from './pasteTokens'
|
||
import { dataUrlToImagePart, type AttachedImage } from './imageUtils'
|
||
import type { AttachedTextFile } from './textFileUtils'
|
||
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
|
||
import { workspaceStore } from '$lib/stores'
|
||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
|
||
import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields'
|
||
import type { FunctionParameters } from 'openai/resources/shared.mjs'
|
||
import { z } from 'zod'
|
||
import {
|
||
ScriptService,
|
||
FlowService,
|
||
JobService,
|
||
type Job,
|
||
type CompletedJob,
|
||
type FlowValue,
|
||
type FlowModule,
|
||
type ScriptLang,
|
||
type Script,
|
||
type Flow
|
||
} from '$lib/gen'
|
||
import uFuzzy from '@leeoniya/ufuzzy'
|
||
import { emptyString } from '$lib/utils'
|
||
import { logFeatureUsage, logHubScriptPick } from '$lib/utils/featureUsage'
|
||
import { forLater } from '$lib/forLater'
|
||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||
import { getCurrentModel } from '$lib/aiStore'
|
||
import { type editor as meditor } from 'monaco-editor'
|
||
import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions'
|
||
|
||
// Prettify function for code arguments - extracts and formats code from JSON
|
||
function prettifyCodeArguments(content: string): string {
|
||
let codeContent = content
|
||
|
||
// If it's a JSON string, try to extract the code property
|
||
if (typeof content === 'string' && content.trim().startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
if (parsed.code) {
|
||
codeContent = parsed.code
|
||
}
|
||
} catch {
|
||
// If JSON is incomplete during streaming, try to extract manually
|
||
// Remove leading { "code": " or {"code":"
|
||
codeContent = content.replace(/^\{\s*"code"\s*:\s*"/, '')
|
||
// Remove trailing } if it exists
|
||
codeContent = codeContent.replace(/"\s*}\s*$/, '')
|
||
}
|
||
}
|
||
|
||
// Convert escaped newlines to actual newlines
|
||
codeContent = codeContent.replace(/\\n/g, '\n')
|
||
|
||
// Convert other common escape sequences
|
||
codeContent = codeContent.replace(/\\t/g, '\t')
|
||
codeContent = codeContent.replace(/\\"/g, '"')
|
||
codeContent = codeContent.replace(/\\\\/g, '\\')
|
||
|
||
return codeContent
|
||
}
|
||
|
||
function decodeEscapedToolString(content: string): string {
|
||
return content
|
||
.replace(/\\n/g, '\n')
|
||
.replace(/\\t/g, '\t')
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\\\/g, '\\')
|
||
}
|
||
|
||
function extractJsonStringProperty(content: string, property: string): string | undefined {
|
||
const propertyKey = `"${property}"`
|
||
const propertyIndex = content.indexOf(propertyKey)
|
||
if (propertyIndex === -1) {
|
||
return undefined
|
||
}
|
||
|
||
let cursor = propertyIndex + propertyKey.length
|
||
while (cursor < content.length && /\s/.test(content[cursor] ?? '')) {
|
||
cursor++
|
||
}
|
||
if (content[cursor] !== ':') {
|
||
return undefined
|
||
}
|
||
|
||
cursor++
|
||
while (cursor < content.length && /\s/.test(content[cursor] ?? '')) {
|
||
cursor++
|
||
}
|
||
if (content[cursor] !== '"') {
|
||
return undefined
|
||
}
|
||
|
||
const start = cursor + 1
|
||
let escaped = false
|
||
for (let index = start; index < content.length; index++) {
|
||
const char = content[index]
|
||
if (escaped) {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if (char === '\\') {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if (char === '"') {
|
||
return content.slice(start, index)
|
||
}
|
||
}
|
||
|
||
return content.slice(start)
|
||
}
|
||
|
||
// Prettify function for set_module_code - extracts code from moduleId/code JSON
|
||
function prettifySetModuleCode(content: string): string {
|
||
let codeContent = content
|
||
|
||
if (typeof content === 'string' && content.trim().startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
if (parsed.code) {
|
||
codeContent = parsed.code
|
||
}
|
||
} catch {
|
||
const extractedCode = extractJsonStringProperty(content, 'code')
|
||
if (extractedCode !== undefined) {
|
||
codeContent = extractedCode
|
||
}
|
||
}
|
||
}
|
||
|
||
return decodeEscapedToolString(codeContent)
|
||
}
|
||
|
||
function prettifyPatchReplacement(content: string): string {
|
||
let newString: string | undefined
|
||
|
||
if (typeof content === 'string' && content.trim().startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
if (typeof parsed.new_string === 'string') {
|
||
newString = parsed.new_string
|
||
}
|
||
} catch {
|
||
newString = extractJsonStringProperty(content, 'new_string')
|
||
}
|
||
}
|
||
|
||
if (newString === undefined) {
|
||
return content
|
||
}
|
||
|
||
return decodeEscapedToolString(newString)
|
||
}
|
||
|
||
function prettifyPatchFlowJson(content: string): string {
|
||
return prettifyPatchReplacement(content)
|
||
}
|
||
|
||
function prettifyPatchFile(content: string): string {
|
||
return prettifyPatchReplacement(content)
|
||
}
|
||
|
||
// Prettify function for module value JSON - extracts the 'value' property and formats it
|
||
function prettifyModuleValue(content: string): string {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
// Extract just the 'value' property (the actual module definition)
|
||
if (parsed.value) {
|
||
return JSON.stringify(parsed.value, null, 2)
|
||
}
|
||
return JSON.stringify(parsed, null, 2)
|
||
} catch {
|
||
// If JSON is incomplete during streaming, try to extract the value property manually
|
||
const valueMatch = content.match(/"value"\s*:\s*(\{[\s\S]*)$/)
|
||
if (valueMatch) {
|
||
let valueContent = valueMatch[1]
|
||
// Try to parse and format the extracted value
|
||
try {
|
||
// Find the matching closing brace for the value object
|
||
let braceCount = 0
|
||
let endIndex = 0
|
||
for (let i = 0; i < valueContent.length; i++) {
|
||
if (valueContent[i] === '{') braceCount++
|
||
else if (valueContent[i] === '}') braceCount--
|
||
if (braceCount === 0) {
|
||
endIndex = i + 1
|
||
break
|
||
}
|
||
}
|
||
if (endIndex > 0) {
|
||
const valueJson = valueContent.substring(0, endIndex)
|
||
const parsed = JSON.parse(valueJson)
|
||
return JSON.stringify(parsed, null, 2)
|
||
}
|
||
} catch {
|
||
// If parsing fails, just unescape and return the extracted value content
|
||
valueContent = valueContent.replace(/\\n/g, '\n')
|
||
valueContent = valueContent.replace(/\\t/g, '\t')
|
||
valueContent = valueContent.replace(/\\"/g, '"')
|
||
valueContent = valueContent.replace(/\\\\/g, '\\')
|
||
return valueContent
|
||
}
|
||
}
|
||
// Fallback: just unescape and return
|
||
let result = content
|
||
result = result.replace(/\\n/g, '\n')
|
||
result = result.replace(/\\t/g, '\t')
|
||
result = result.replace(/\\"/g, '"')
|
||
result = result.replace(/\\\\/g, '\\')
|
||
return result
|
||
}
|
||
}
|
||
|
||
// Map of tool names to their prettify functions
|
||
export const TOOL_PRETTIFY_MAP: Record<string, (content: string) => string> = {
|
||
edit_code: prettifyCodeArguments,
|
||
set_module_code: prettifySetModuleCode,
|
||
patch_flow_json: prettifyPatchFlowJson,
|
||
patch_file: prettifyPatchFile,
|
||
add_module: prettifyModuleValue,
|
||
modify_module: prettifyModuleValue
|
||
}
|
||
|
||
export interface ContextStringResult {
|
||
dbContext: string
|
||
diffContext: string
|
||
flowModuleContext: string
|
||
hasDb: boolean
|
||
hasDiff: boolean
|
||
hasFlowModule: boolean
|
||
}
|
||
|
||
/** Count exact occurrences of `search` in `content`. */
|
||
export function countExactMatches(content: string, search: string): number {
|
||
if (search.length === 0) return 0
|
||
let count = 0
|
||
let index = 0
|
||
while ((index = content.indexOf(search, index)) !== -1) {
|
||
count++
|
||
index += search.length
|
||
}
|
||
return count
|
||
}
|
||
|
||
/**
|
||
* Replace exact occurrences of `oldString` with `newString` in `content`.
|
||
* When `replaceAll` is false, only the first match is replaced. Returns the
|
||
* original string unchanged when no match is found.
|
||
*/
|
||
export function applyExactReplace(
|
||
content: string,
|
||
oldString: string,
|
||
newString: string,
|
||
replaceAll: boolean
|
||
): string {
|
||
if (replaceAll) return content.split(oldString).join(newString)
|
||
const index = content.indexOf(oldString)
|
||
if (index === -1) return content
|
||
return content.slice(0, index) + newString + content.slice(index + oldString.length)
|
||
}
|
||
|
||
/**
|
||
* Match-count-validated exact text replacement. Throws when `oldString` is
|
||
* missing, and (unless `replaceAll`) when it appears more than once.
|
||
* `contextLabel` flows into the error message ("not found in the <label>.").
|
||
*/
|
||
export function findAndReplace(
|
||
content: string,
|
||
oldString: string,
|
||
newString: string,
|
||
replaceAll: boolean,
|
||
contextLabel: string
|
||
): string {
|
||
const matchCount = countExactMatches(content, oldString)
|
||
if (matchCount === 0) {
|
||
throw new Error(`old_string was not found in the ${contextLabel}.`)
|
||
}
|
||
if (!replaceAll && matchCount !== 1) {
|
||
throw new Error(
|
||
`old_string matched ${matchCount} locations. Make it more specific or set replace_all to true.`
|
||
)
|
||
}
|
||
return applyExactReplace(content, oldString, newString, replaceAll)
|
||
}
|
||
|
||
export const extractAllModules = (modules: FlowModule[]): FlowModule[] => {
|
||
return modules.flatMap((m) => {
|
||
if (m.value.type === 'forloopflow' || m.value.type === 'whileloopflow') {
|
||
return [m, ...extractAllModules(m.value.modules)]
|
||
}
|
||
if (m.value.type === 'branchall') {
|
||
return [m, ...extractAllModules(m.value.branches.flatMap((b) => b.modules))]
|
||
}
|
||
if (m.value.type === 'branchone') {
|
||
return [
|
||
m,
|
||
...extractAllModules([...m.value.branches.flatMap((b) => b.modules), ...m.value.default])
|
||
]
|
||
}
|
||
return [m]
|
||
})
|
||
}
|
||
|
||
const applyCodePieceToCodeContext = (codePieces: CodePieceElement[], codeContext: string) => {
|
||
let code = codeContext.split('\n')
|
||
let shiftOffset = 0
|
||
codePieces.sort((a, b) => a.startLine - b.startLine)
|
||
for (const codePiece of codePieces) {
|
||
code.splice(codePiece.endLine + shiftOffset, 0, '[#END]')
|
||
code.splice(codePiece.startLine + shiftOffset - 1, 0, '[#START]')
|
||
shiftOffset += 2
|
||
}
|
||
return code.join('\n')
|
||
}
|
||
|
||
export function applyCodePiecesToFlowModules(
|
||
codePieces: FlowModuleCodePieceElement[],
|
||
flowModules: FlowModule[]
|
||
): FlowModule[] {
|
||
const moduleCodePieces = new Map<string, FlowModuleCodePieceElement[]>()
|
||
for (const codePiece of codePieces) {
|
||
const moduleId = codePiece.id
|
||
if (!moduleCodePieces.has(moduleId)) {
|
||
moduleCodePieces.set(moduleId, [])
|
||
}
|
||
moduleCodePieces.get(moduleId)!.push(codePiece)
|
||
}
|
||
|
||
// Clone modules to avoid mutation
|
||
const modifiedModules = JSON.parse(JSON.stringify(flowModules))
|
||
|
||
// Apply code pieces to each module
|
||
for (const [moduleId, pieces] of moduleCodePieces) {
|
||
const module = findModuleInModules(modifiedModules, moduleId)
|
||
if (module && module.value.type === 'rawscript' && module.value.content) {
|
||
module.value.content = applyCodePieceToCodeContext(
|
||
pieces as unknown as CodePieceElement[],
|
||
module.value.content
|
||
)
|
||
}
|
||
}
|
||
|
||
return modifiedModules
|
||
}
|
||
|
||
export function buildContextString(selectedContext: ContextElement[]): string {
|
||
const dbTemplate = `- {title}: SCHEMA: \n{schema}\n`
|
||
const codeTemplate = `
|
||
- {title}:
|
||
\`\`\`{language}
|
||
{code}
|
||
\`\`\`
|
||
`
|
||
|
||
let dbContext = 'DATABASES:\n'
|
||
let diffContext = 'DIFF:\n'
|
||
let flowModuleContext = 'FOCUSED FLOW MODULES IDS:\n'
|
||
let codeContext = 'CODE:\n'
|
||
let errorContext = `
|
||
ERROR:
|
||
{error}
|
||
`
|
||
let hasCode = false
|
||
let hasDb = false
|
||
let hasDiff = false
|
||
let hasFlowModule = false
|
||
let hasError = false
|
||
let workspaceItemsContext = ''
|
||
|
||
let result = '\n\n'
|
||
for (const context of selectedContext) {
|
||
if (context.type === 'code') {
|
||
hasCode = true
|
||
codeContext += codeTemplate
|
||
.replace('{title}', context.title)
|
||
.replace('{language}', scriptLangToEditorLang(context.lang))
|
||
.replace(
|
||
'{code}',
|
||
applyCodePieceToCodeContext(
|
||
selectedContext.filter((c) => c.type === 'code_piece'),
|
||
context.content
|
||
)
|
||
)
|
||
} else if (context.type === 'error') {
|
||
if (hasError) {
|
||
throw new Error('Multiple error contexts provided')
|
||
}
|
||
hasError = true
|
||
errorContext = errorContext.replace('{error}', context.content)
|
||
} else if (context.type === 'db') {
|
||
hasDb = true
|
||
dbContext += dbTemplate
|
||
.replace('{title}', context.title)
|
||
.replace('{schema}', context.schema?.stringified ?? 'to fetch with get_db_schema')
|
||
dbContext += '\n'
|
||
} else if (context.type === 'diff') {
|
||
hasDiff = true
|
||
const diff = JSON.stringify(context.diff)
|
||
diffContext += (diff.length > 3000 ? diff.slice(0, 3000) + '...' : diff) + '\n'
|
||
} else if (context.type === 'flow_module') {
|
||
hasFlowModule = true
|
||
flowModuleContext += `${context.id}\n`
|
||
} else if (context.type === 'workspace_script') {
|
||
if (!workspaceItemsContext) {
|
||
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
|
||
}
|
||
workspaceItemsContext += `- type: script, path: ${context.path}\n`
|
||
} else if (context.type === 'workspace_flow') {
|
||
if (!workspaceItemsContext) {
|
||
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
|
||
}
|
||
workspaceItemsContext += `- type: flow, path: ${context.path}\n`
|
||
} else if (context.type === 'workspace_app') {
|
||
if (!workspaceItemsContext) {
|
||
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
|
||
}
|
||
workspaceItemsContext += `- type: raw_app, path: ${context.path}\n`
|
||
}
|
||
}
|
||
|
||
if (hasCode) {
|
||
result += '\n' + codeContext
|
||
}
|
||
if (hasError) {
|
||
result += '\n' + errorContext
|
||
}
|
||
if (hasDb) {
|
||
result += '\n' + dbContext
|
||
}
|
||
if (hasDiff) {
|
||
result += '\n' + diffContext
|
||
}
|
||
if (hasFlowModule) {
|
||
result += '\n' + flowModuleContext
|
||
}
|
||
if (workspaceItemsContext) {
|
||
result += '\n' + workspaceItemsContext
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
type BaseDisplayMessage = {
|
||
content: string
|
||
contextElements?: ContextElement[]
|
||
snapshot?: { type: 'flow'; value: ExtendedOpenFlow } | { type: 'app'; value: number }
|
||
}
|
||
|
||
export type UserDisplayMessage = BaseDisplayMessage & {
|
||
role: 'user'
|
||
index: number // Used to match index with actual chat messages
|
||
error?: boolean
|
||
// Collapsed big-paste blobs referenced by tokens in `content`. Lets the
|
||
// bubble render/expand chips; the LLM message stores the expanded text.
|
||
pastes?: PasteAttachment[]
|
||
// Images the user attached to this message (drag/drop/paste), rendered as
|
||
// thumbnails in the bubble. The LLM message carries them as image_url parts.
|
||
images?: AttachedImage[]
|
||
// Text files the user attached to this message, rendered as chips in the
|
||
// bubble. The prompt lists them by reference; the content here is the durable
|
||
// copy, re-registered into the session file store on load for tool reads.
|
||
files?: AttachedTextFile[]
|
||
// The client authored this turn itself (background-job auto-resume), not the
|
||
// user — ArrowUp recall must skip it.
|
||
synthetic?: boolean
|
||
}
|
||
|
||
export type CreatedResourceTriggerKind =
|
||
| 'http'
|
||
| 'websocket'
|
||
| 'kafka'
|
||
| 'nats'
|
||
| 'postgres'
|
||
| 'mqtt'
|
||
| 'amqp'
|
||
| 'sqs'
|
||
| 'gcp'
|
||
| 'azure'
|
||
| 'email'
|
||
|
||
export type CreatedResourceAction = {
|
||
id: string
|
||
type: 'open_created_resource'
|
||
label: string
|
||
resource: 'schedule' | 'trigger' | 'resource' | 'variable'
|
||
path: string
|
||
targetKind?: 'script' | 'flow'
|
||
triggerKind?: CreatedResourceTriggerKind
|
||
}
|
||
|
||
// A clickable chip that deep-links the user to an in-app page (e.g. Runs filtered to
|
||
// a script's failures). Used for cross-page navigation from the chat; the handler is
|
||
// registered by a top-level layout and calls `goto(url)`.
|
||
export type NavigateAction = {
|
||
id: string
|
||
type: 'navigate'
|
||
label: string
|
||
url: string
|
||
// Which page the chip opens (runs, schedules, variables, …); drives its icon/title.
|
||
page: string
|
||
}
|
||
|
||
// Re-exported: most consumers reach these through this module.
|
||
export { openItemPreviewAction, type PreviewCardKind, type OpenItemPreviewAction }
|
||
|
||
export type ToolDisplayAction = CreatedResourceAction | NavigateAction | OpenItemPreviewAction
|
||
|
||
export type UserQuestionDisplay = {
|
||
question: string
|
||
choices: string[]
|
||
multiSelect?: boolean
|
||
selectedChoices?: string[] // canonical answer (new code writes only this)
|
||
selectedChoice?: string // legacy/read-only: pre-multiselect persisted history
|
||
canceled?: boolean
|
||
}
|
||
|
||
// The single place that understands the legacy answer shape: new code writes
|
||
// selectedChoices, but history persisted before multi-select only has the
|
||
// scalar selectedChoice. Read answers through this so both shapes resolve.
|
||
export function answeredChoices(q: UserQuestionDisplay): string[] | undefined {
|
||
return q.selectedChoices ?? (q.selectedChoice ? [q.selectedChoice] : undefined)
|
||
}
|
||
|
||
/** Argument form for a deployed-script run, persisted with the transcript — every
|
||
* field has to stay plain JSON. */
|
||
export type RunFormDisplay = {
|
||
path: string
|
||
summary?: string
|
||
/** What the run is, in the card's own words: a deployed script run, or a preview of the
|
||
* draft being written. Only the tense of the row's label turns on it. */
|
||
kind?: 'run' | 'test'
|
||
/** What is being run, for the noun the card says it in.
|
||
* Absent on cards recorded before flows had a form, which were all scripts. */
|
||
runnableKind?: 'script' | 'flow'
|
||
/** Of whatever version is about to run: the deployed script, or the draft a test
|
||
* previews. Only the rendered form reads it, so it is dropped once one of the flags
|
||
* below unmounts that form: kept, every settled card would carry a copy of the schema
|
||
* — password and file defaults included — in history forever. */
|
||
schema?: Record<string, any>
|
||
/** The script the `dynselect-` helper runs: the draft a script test run previews, since a
|
||
* deployed helper would answer for the wrong version, or the one a flow schema carries on
|
||
* itself. Dropped with the schema once the form unmounts, so no settled card keeps a copy
|
||
* of the code. */
|
||
code?: string
|
||
lang?: ScriptLang
|
||
/** Prefill only: the card's `parameters` records what the job started with. */
|
||
args: Record<string, any>
|
||
/** Proposed arguments emptied because their declared type had no reading of them.
|
||
* Named on the card: an empty field is otherwise the caller having sent nothing. */
|
||
clearedKeys?: string[]
|
||
/** Proposed arguments a disabled field overrode with its default. Named for the same
|
||
* reason: the field renders locked, so the value it holds is not the proposed one. */
|
||
resetKeys?: string[]
|
||
/** File arguments emptied out of the proposal. Named so an empty field reads as the
|
||
* caller's value having been removed, not as the field having none. */
|
||
strippedKeys?: string[]
|
||
/** Either one unmounts the form, so set exactly one, and only once the loop has
|
||
* stopped waiting on this card. */
|
||
submitted?: boolean
|
||
canceled?: boolean
|
||
/** The job exists. Distinct from `submitted`, which flips a round trip earlier — in
|
||
* between, whether the server queued a job is unknown, so a turn stopped there is
|
||
* recorded as neither started nor canceled. */
|
||
started?: boolean
|
||
}
|
||
|
||
/** What a run form is being filled with while it waits. Held by the chat manager, not by
|
||
* the form, so the chat card and the preview pane edit one draft rather than two copies.
|
||
* The schema rides along because SchemaForm binds and reorders it. */
|
||
export type RunFormDraft = {
|
||
args: Record<string, any>
|
||
schema: Record<string, any>
|
||
}
|
||
|
||
/** One page hit from a provider-side web search (OpenAI sources carry no title). */
|
||
export type WebSearchSource = {
|
||
url: string
|
||
title?: string
|
||
}
|
||
|
||
export type ToolCodeDiff = {
|
||
before: string
|
||
after: string
|
||
/** Monaco language id. */
|
||
lang: string
|
||
}
|
||
|
||
/** The result shape any tool returns to have it rendered as a web search card. */
|
||
export type WebSearchResult = {
|
||
sources: WebSearchSource[]
|
||
query?: string
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
/** A link the card can render: the source list drops everything else, so a result whose
|
||
* urls are relative or `javascript:` would show as an empty list in place of its own output. */
|
||
export function isRenderableSourceUrl(url: string): boolean {
|
||
try {
|
||
return ['http:', 'https:'].includes(new URL(url).protocol)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a
|
||
* TypeScript one, so both read as absent. */
|
||
function isOptionalString(value: unknown): boolean {
|
||
return value === null || typeof value === 'string'
|
||
}
|
||
|
||
function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } {
|
||
return (
|
||
isRecord(value) &&
|
||
typeof value.url === 'string' &&
|
||
isRenderableSourceUrl(value.url) &&
|
||
Object.entries(value).every(
|
||
([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v))
|
||
)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A tool result as a web search, or undefined when it is anything else. The keys must be
|
||
* exactly `sources` and an optional `query`: the card renders only urls and titles, so a
|
||
* result carrying more would lose it. Accepts the result as its JSON text too.
|
||
*/
|
||
export function webSearchResultOf(result: unknown): WebSearchResult | undefined {
|
||
if (typeof result === 'string') {
|
||
try {
|
||
result = JSON.parse(result)
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) {
|
||
return undefined
|
||
}
|
||
const sources = result.sources
|
||
if (
|
||
!sources.every(isWebSearchSource) ||
|
||
!Object.entries(result).every(
|
||
([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v))
|
||
)
|
||
) {
|
||
return undefined
|
||
}
|
||
return {
|
||
sources: sources.map((source) => ({
|
||
url: source.url,
|
||
title: typeof source.title === 'string' ? source.title : undefined
|
||
})),
|
||
query: typeof result.query === 'string' ? result.query : undefined
|
||
}
|
||
}
|
||
|
||
export type ToolDisplayMessage = {
|
||
role: 'tool'
|
||
tool_call_id: string
|
||
content: string
|
||
parameters?: any
|
||
result?: any
|
||
/** What the job has streamed of its result so far, while it is still running.
|
||
* Cleared when the job lands: `result` is then the whole of it. */
|
||
resultStream?: string
|
||
logs?: string
|
||
isLoading?: boolean
|
||
/** Arguments fully streamed but execution not started (see queuedToolStatus). */
|
||
isQueued?: boolean
|
||
error?: string
|
||
needsConfirmation?: boolean
|
||
showDetails?: boolean
|
||
autoCollapseDetails?: boolean
|
||
isStreamingArguments?: boolean
|
||
toolName?: string
|
||
/** What marks this row with its provider. Recorded rather than looked up, because the
|
||
* server listing lives only in memory and a reloaded transcript does not have it. The
|
||
* workspace rides along: a chat is readable from any workspace, and the same path names
|
||
* a different server in each. */
|
||
mcpServer?: { workspace: string; path: string }
|
||
/** The run behind the call. Flow chats only: the card links to it. */
|
||
jobId?: string
|
||
showFade?: boolean
|
||
actions?: ToolDisplayAction[]
|
||
userQuestion?: UserQuestionDisplay
|
||
runForm?: RunFormDisplay
|
||
/** A run this call inspected rather than started, rendered by the same card. The card
|
||
* reads its panes from this job, so the user sees its own args and result in full, and its
|
||
* logs as a 4000-char tail, while the model keeps the capped envelope the tool returned.
|
||
* `runId` and `step` are the address the call was made with, kept so the card can name what
|
||
* was inspected the way the tool's own row did: a step job names neither the step nor the
|
||
* run it belongs to. */
|
||
inspectedRun?: { jobId: string; workspace: string; runId: string; step?: string }
|
||
webSearchSources?: WebSearchSource[]
|
||
/** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */
|
||
imageUrl?: string
|
||
/** Workspace item this tool created or updated. Rendered as a discrete,
|
||
* always-visible card that opens (or focuses) the item's preview in the
|
||
* session side panel. Set only for session chats — the side panel is their surface. */
|
||
previewCard?: { kind: PreviewCardKind; path: string }
|
||
/** Whole text on both sides of an edit this call saved. Kept in full rather than as a
|
||
* reference: drafts are overwritten in place, so the sides cannot be fetched back later. */
|
||
codeDiff?: ToolCodeDiff
|
||
planArtifactId?: string
|
||
/** The version this card's proposal wrote, so a card scrolled far up still opens the plan
|
||
* it proposed rather than what the document became. */
|
||
planVersion?: number
|
||
/** Refused by the plan-mode gate. Renders as its own lean row rather than a tool
|
||
* error, so the transcript says the mode stopped it and not that the call failed. */
|
||
blockedByPlanMode?: boolean
|
||
/** Held back until the model has read the folder instructions it was handed. */
|
||
heldForFolderInstructions?: boolean
|
||
/** The user declined: the reject button, a Stop, or a posture switch. Set only there, so
|
||
* a decision is distinguishable from every other way a call errors. */
|
||
declinedByUser?: boolean
|
||
}
|
||
|
||
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||
role: 'assistant'
|
||
/** Summarized reasoning/thinking text streamed before the answer (Anthropic + compat providers). */
|
||
reasoning?: string
|
||
/** Wall time the model spent reasoning, from the first thinking token to the
|
||
* first answer token. Absent on messages finalized before it was recorded. */
|
||
reasoningDurationMs?: number
|
||
/**
|
||
* True only on the synthetic live message appended while tokens stream
|
||
* (see AIChat.svelte). Finalized messages never set it — without the flag,
|
||
* a reasoning-only message (thinking that led straight to a tool call)
|
||
* would look like it is still streaming forever.
|
||
*/
|
||
streaming?: boolean
|
||
/** Flow step that produced this message, when the conversation is a flow run
|
||
* rather than a copilot turn. Rendered as a label above the content. */
|
||
stepName?: string
|
||
/** The run behind this answer. Flow chats only: a copilot turn happens in the
|
||
* browser and has no job. */
|
||
jobId?: string
|
||
/** When the answer arrived: the server's time for a flow chat's stored row, the browser's
|
||
* for a copilot answer, which is stamped as it lands. Absent on a copilot chat restored
|
||
* from history, which predates the stamp. */
|
||
createdAt?: string
|
||
}
|
||
|
||
/**
|
||
* Compaction boundary: replaces the summarized prefix in BOTH displayMessages
|
||
* and the API messages (where it is a plain user message). It is never a restart
|
||
* target — only the surviving tail's user messages are rewound to.
|
||
*/
|
||
export type SummaryDisplayMessage = {
|
||
role: 'summary'
|
||
content: string
|
||
// Index of the summary's API message, tracked ONLY so orphan detection can tell
|
||
// when a later drop-oldest compaction drops it (index goes negative) and its
|
||
// carried files must move to the roster. Not a restart target. Absent on
|
||
// summaries loaded from pre-existing history.
|
||
index?: number
|
||
// Files attached to messages the summary folded away — carried forward so
|
||
// they stay tool-readable (and reload-safe) after compaction.
|
||
files?: AttachedTextFile[]
|
||
}
|
||
|
||
export type DisplayMessage =
|
||
| UserDisplayMessage
|
||
| ToolDisplayMessage
|
||
| AssistantDisplayMessage
|
||
| SummaryDisplayMessage
|
||
|
||
// A tool message whose askUserQuestion is still awaiting an answer: the AI loop
|
||
// is paused on the user. Drives the question card's interactivity, the
|
||
// "waiting for user" indicator, and routing a composer send to the answer —
|
||
// keep those in sync by going through this single predicate.
|
||
export function isActiveUserQuestion(message: DisplayMessage | undefined): boolean {
|
||
return Boolean(
|
||
message &&
|
||
message.role === 'tool' &&
|
||
message.userQuestion &&
|
||
message.isLoading &&
|
||
!message.error &&
|
||
!answeredChoices(message.userQuestion)?.length &&
|
||
!message.userQuestion.canceled
|
||
)
|
||
}
|
||
|
||
export function isActiveRunForm(message: DisplayMessage | undefined): boolean {
|
||
return Boolean(
|
||
message &&
|
||
message.role === 'tool' &&
|
||
message.runForm &&
|
||
message.isLoading &&
|
||
!message.error &&
|
||
!message.runForm.submitted &&
|
||
!message.runForm.canceled
|
||
)
|
||
}
|
||
|
||
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
|
||
// staged for confirmation. The manager stays `loading` through both, so anything
|
||
// rendering progress must ask here first or it reports "the AI is working".
|
||
export type PendingUserAction = 'question' | 'confirmation'
|
||
|
||
export function pendingUserAction(messages: DisplayMessage[]): PendingUserAction | undefined {
|
||
return pendingUserActionDetail(messages)?.action
|
||
}
|
||
|
||
// Scans back to the turn boundary, not just the last message: a turn's cards are
|
||
// created up front and run one at a time, and text between two tool calls pushes
|
||
// an assistant card between them, so the blocked card is rarely last. Only cards
|
||
// of a live turn can match — every resolution path clears `isLoading`.
|
||
export function pendingUserActionDetail(
|
||
messages: DisplayMessage[]
|
||
): { action: PendingUserAction; toolCallId: string } | undefined {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const message = messages[i]
|
||
if (message.role === 'user') break
|
||
if (message.role !== 'tool') continue
|
||
if (isActiveUserQuestion(message)) {
|
||
return { action: 'question', toolCallId: message.tool_call_id }
|
||
}
|
||
// A run form is a confirmation carrying arguments, not a question: it parks the
|
||
// turn the same way, but Run or Cancel resolves it and typing never does — so it
|
||
// must not claim the answer affordance a pending question offers.
|
||
if (isActiveRunForm(message)) {
|
||
return { action: 'confirmation', toolCallId: message.tool_call_id }
|
||
}
|
||
if (message.needsConfirmation && message.isLoading) {
|
||
return { action: 'confirmation', toolCallId: message.tool_call_id }
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
// Fires after every tool call resolves, with the tool name. Lets a host (e.g.
|
||
// the sessions page) react to mutating tools — refreshing previews — without
|
||
// the tool layer knowing about the UI. Single slot; the consumer filters by name
|
||
// and reads the tool args (e.g. the mutated item's `path`) to scope its refresh.
|
||
// `workspace` is the one the tool acted on: a path names an item only within it.
|
||
export type ToolCompletionListener = (toolName: string, args: any, workspace: string) => void
|
||
let toolCompletionListener: ToolCompletionListener | undefined
|
||
|
||
export function setToolCompletionListener(fn: ToolCompletionListener | undefined): void {
|
||
toolCompletionListener = fn
|
||
}
|
||
|
||
async function callTool<T>({
|
||
tools,
|
||
functionName,
|
||
args,
|
||
workspace,
|
||
helpers,
|
||
toolCallbacks,
|
||
toolId
|
||
}: {
|
||
tools: Tool<T>[]
|
||
functionName: string
|
||
args: any
|
||
workspace: string
|
||
helpers: T
|
||
toolCallbacks: ToolCallbacks
|
||
toolId: string
|
||
}): Promise<string> {
|
||
const tool = tools.find((t) => t.def.function.name === functionName)
|
||
if (!tool) {
|
||
throw new Error(
|
||
`Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.`
|
||
)
|
||
}
|
||
const result = await tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
|
||
toolCompletionListener?.(functionName, args, workspace)
|
||
return result
|
||
}
|
||
|
||
type MaybePromise<T> = T | Promise<T>
|
||
|
||
/** A refused tool call: the row the user reads, and the result the model gets. A bare string
|
||
* is both at once. */
|
||
export type ToolRejection = string | { label: string; result: string }
|
||
|
||
function normalizeToolRejection(
|
||
rejection: ToolRejection | undefined
|
||
): { label: string; result: string } | undefined {
|
||
if (rejection === undefined) return undefined
|
||
return typeof rejection === 'string' ? { label: rejection, result: rejection } : rejection
|
||
}
|
||
|
||
/**
|
||
* Key paths present in `supplied` that a strip-mode parse discarded. Sub-fields of a
|
||
* schedule's `retry` are all optional, so a guessed shape validates clean, loses the
|
||
* misspelled keys and saves a policy that does nothing. Recursive because dropping one
|
||
* nested key leaves the parent non-empty.
|
||
*/
|
||
export function droppedOptionKeys(supplied: unknown, parsed: unknown, prefix = ''): string[] {
|
||
if (supplied === null || typeof supplied !== 'object' || Array.isArray(supplied)) {
|
||
return parsed === undefined && prefix ? [prefix] : []
|
||
}
|
||
if (parsed === null || typeof parsed !== 'object') {
|
||
return Object.keys(supplied).length && prefix ? [prefix] : []
|
||
}
|
||
return Object.entries(supplied).flatMap(([key, value]) =>
|
||
droppedOptionKeys(
|
||
value,
|
||
(parsed as Record<string, unknown>)[key],
|
||
prefix ? `${prefix}.${key}` : key
|
||
)
|
||
)
|
||
}
|
||
|
||
const MAX_TOOL_ERROR_LENGTH = 2000
|
||
|
||
/** ApiError from the generated client carries the server's message in `body`,
|
||
* not `message` — dig it out so tool failures show the real cause. Capped so a
|
||
* verbose error body (e.g. an HTML error page) can't flood the chat context. */
|
||
export function formatToolError(error: any): string {
|
||
const bodyMessage =
|
||
error?.body?.error?.message ??
|
||
error?.body?.message ??
|
||
(typeof error?.body?.error === 'string' ? error.body.error : undefined)
|
||
const body =
|
||
bodyMessage ??
|
||
(typeof error?.body === 'string'
|
||
? error.body
|
||
: error?.body !== undefined
|
||
? stringifyErrorBody(error.body)
|
||
: undefined)
|
||
const message = String(body || error?.message || error)
|
||
return message.length > MAX_TOOL_ERROR_LENGTH
|
||
? message.slice(0, MAX_TOOL_ERROR_LENGTH) + '... (truncated)'
|
||
: message
|
||
}
|
||
|
||
function stringifyErrorBody(body: unknown): string {
|
||
try {
|
||
return JSON.stringify(body)
|
||
} catch {
|
||
return String(body)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Closed vocabulary for the `ai_chat`/`tool` counter's `<name>:<status>` key.
|
||
* `ok` means the tool function resolved — tools that report failure by returning an
|
||
* error string instead of throwing land there too. A call abandoned mid-execution
|
||
* (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that
|
||
* finished, not to the calls made.
|
||
*/
|
||
type ToolCallStatus =
|
||
| 'ok'
|
||
| 'error'
|
||
| 'declined'
|
||
| 'rejected'
|
||
| 'blocked_plan_mode'
|
||
| 'held_for_instructions'
|
||
|
||
export async function processToolCall<T>({
|
||
tools,
|
||
toolCall,
|
||
helpers,
|
||
toolCallbacks,
|
||
workspace,
|
||
messages = []
|
||
}: {
|
||
tools: Tool<T>[]
|
||
toolCall: ChatCompletionMessageFunctionToolCall
|
||
helpers: T
|
||
toolCallbacks: ToolCallbacks
|
||
workspace?: string
|
||
/** The conversation so far, for which folder instructions it already carries. */
|
||
messages?: readonly ChatCompletionMessageParam[]
|
||
}): Promise<ChatCompletionMessageParam> {
|
||
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
|
||
const workspaceId = workspace ?? get(workspaceStore) ?? ''
|
||
|
||
// Exactly once per call, on whichever path ends it. Keyed by the resolved tool's
|
||
// declared name, not the model-provided string, so hallucinated tool names never
|
||
// enter telemetry — an unresolved name is counted nowhere.
|
||
let outcomeLogged = false
|
||
const logToolOutcome = (status: ToolCallStatus) => {
|
||
if (!tool || outcomeLogged) return
|
||
outcomeLogged = true
|
||
logFeatureUsage('ai_chat', 'tool', {
|
||
key: `${tool.def.function.name}:${status}`,
|
||
workspace: workspaceId
|
||
})
|
||
}
|
||
|
||
try {
|
||
const args = JSON.parse(toolCall.function.arguments || '{}')
|
||
|
||
// Fails closed: untagged is blocked, only the safety tag exempt. Runs before anything
|
||
// belonging to the tool, so a validator cannot probe while planning — and again after
|
||
// the confirmation wait, since plan mode can be entered while a card is pending.
|
||
// An unresolved name is left alone, so it still reads as the unknown tool it is.
|
||
const planModeBlock = (): ChatCompletionMessageParam | undefined => {
|
||
if (!toolCallbacks.isPlanModeActive?.() || !tool) return undefined
|
||
// A tagged tool may still name arguments it refuses: the tag says the tool is usable
|
||
// while planning, not that every call of it is. Asked only once the tag has passed, so
|
||
// it can narrow what the gate admits and never widen it.
|
||
const refusal =
|
||
tool.planModeSafe === true
|
||
? normalizeToolRejection(tool.refuseInPlanMode?.({ args, helpers }))
|
||
: { label: PLAN_MODE_MESSAGES.blockedLabel, result: PLAN_MODE_MESSAGES.blockedResult }
|
||
if (!refusal) return undefined
|
||
toolCallbacks.onToolBlockedByPlanMode?.()
|
||
logToolOutcome('blocked_plan_mode')
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
content: refusal.label,
|
||
parameters: args,
|
||
isLoading: false,
|
||
isQueued: false,
|
||
isStreamingArguments: false,
|
||
error: refusal.result,
|
||
blockedByPlanMode: true,
|
||
needsConfirmation: false,
|
||
showDetails: tool.showDetails,
|
||
autoCollapseDetails: tool.autoCollapseDetails
|
||
})
|
||
return {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: refusal.result
|
||
}
|
||
}
|
||
|
||
const preConfirmationBlock = planModeBlock()
|
||
if (preConfirmationBlock) {
|
||
return preConfirmationBlock
|
||
}
|
||
|
||
const rejection = normalizeToolRejection(
|
||
await tool?.validateBeforeConfirmation?.({ args, workspace: workspaceId, helpers })
|
||
)
|
||
if (rejection) {
|
||
logToolOutcome('rejected')
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
content: rejection.label,
|
||
parameters: args,
|
||
isLoading: false,
|
||
isQueued: false,
|
||
isStreamingArguments: false,
|
||
error: rejection.label,
|
||
needsConfirmation: false,
|
||
showDetails: tool?.showDetails,
|
||
autoCollapseDetails: tool?.autoCollapseDetails
|
||
})
|
||
return {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: rejection.result
|
||
}
|
||
}
|
||
|
||
// After the gates that refuse the call, so a refused call does not use up the
|
||
// delivery. A call that changes something is held back until the model has read
|
||
// the instructions: arriving with its result, they would be too late to shape it.
|
||
const folderInstructions = tool
|
||
? await pendingFolderInstructions(
|
||
toolCallbacks.folderInstructions,
|
||
messages,
|
||
args,
|
||
workspaceId
|
||
)
|
||
: undefined
|
||
if (folderInstructions?.paths.length) {
|
||
toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, {
|
||
workspace: workspaceId,
|
||
paths: folderInstructions.paths
|
||
})
|
||
}
|
||
if (folderInstructions && tool?.planModeSafe !== true) {
|
||
logToolOutcome('held_for_instructions')
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
content: 'Read folder instructions',
|
||
heldForFolderInstructions: true,
|
||
parameters: args,
|
||
isLoading: false,
|
||
isQueued: false,
|
||
isStreamingArguments: false,
|
||
needsConfirmation: false,
|
||
showDetails: tool?.showDetails,
|
||
autoCollapseDetails: tool?.autoCollapseDetails
|
||
})
|
||
return {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}`
|
||
}
|
||
}
|
||
|
||
const requiresConfirmation = tool?.requiresConfirmation === true
|
||
// By name: skipping the wait is itself an answer on the user's behalf, and one tool
|
||
// must not be answered for.
|
||
const autoAcceptConfirmation =
|
||
requiresConfirmation &&
|
||
toolCallbacks.shouldAutoAcceptToolConfirmations?.(toolCall.function.name) === true
|
||
const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
|
||
|
||
const confirmationContent =
|
||
typeof tool?.confirmationMessage === 'function'
|
||
? tool.confirmationMessage(args)
|
||
: tool?.confirmationMessage
|
||
|
||
// preAction fires at promotion, not stream time, so its "-ing" label covers
|
||
// only the execution window — queued cards keep their imperative header.
|
||
// Before the promotion patch, so a confirmation label still wins the header.
|
||
tool?.preAction?.({ toolCallbacks, toolId: toolCall.id })
|
||
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
...(requiresConfirmation
|
||
? { content: confirmationContent ?? 'Waiting for confirmation...' }
|
||
: {}),
|
||
parameters: args,
|
||
isLoading: true,
|
||
isQueued: false,
|
||
needsConfirmation: needsConfirmation,
|
||
showDetails: tool?.showDetails,
|
||
autoCollapseDetails: tool?.autoCollapseDetails
|
||
})
|
||
|
||
// If confirmation is needed and we have the callback, wait for it
|
||
if (needsConfirmation && toolCallbacks.requestConfirmation) {
|
||
tool?.onConfirmationRequested?.({ args, toolCallbacks, toolId: toolCall.id })
|
||
const confirmed = await toolCallbacks.requestConfirmation(toolCall.id, toolCall.function.name)
|
||
|
||
if (!confirmed) {
|
||
logToolOutcome('declined')
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
content: 'Cancelled by user',
|
||
isLoading: false,
|
||
isStreamingArguments: false,
|
||
error: 'Tool execution was cancelled by user',
|
||
declinedByUser: true,
|
||
needsConfirmation: false
|
||
})
|
||
return {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: tool?.cancellationMessage ?? 'Tool execution was cancelled by user'
|
||
}
|
||
}
|
||
|
||
const postConfirmationBlock = planModeBlock()
|
||
if (postConfirmationBlock) {
|
||
return postConfirmationBlock
|
||
}
|
||
|
||
// Update status to executing after confirmation
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
isLoading: true,
|
||
needsConfirmation: false
|
||
})
|
||
}
|
||
|
||
let result = ''
|
||
// A tool that asks for consent itself settles the call as declined or blocked and
|
||
// returns normally, so without this both telemeter as successful runs.
|
||
let settledInsideTool: 'declined' | 'blocked_plan_mode' | undefined = undefined
|
||
try {
|
||
result = await callTool({
|
||
tools,
|
||
functionName: toolCall.function.name,
|
||
args,
|
||
workspace: workspaceId,
|
||
helpers,
|
||
toolCallbacks: {
|
||
...toolCallbacks,
|
||
setToolStatus: (toolId, status) => {
|
||
if (status?.declinedByUser) settledInsideTool = 'declined'
|
||
else if (status?.blockedByPlanMode) settledInsideTool = 'blocked_plan_mode'
|
||
toolCallbacks.setToolStatus(toolId, status)
|
||
}
|
||
},
|
||
toolId: toolCall.id
|
||
})
|
||
logToolOutcome(settledInsideTool ?? 'ok')
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
isLoading: false,
|
||
isStreamingArguments: false
|
||
})
|
||
} catch (err) {
|
||
console.error(err)
|
||
logToolOutcome('error')
|
||
const errorMessage = formatToolError(err)
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
isLoading: false,
|
||
isStreamingArguments: false,
|
||
error: errorMessage
|
||
})
|
||
result = `Error while calling tool: ${errorMessage}`
|
||
}
|
||
const toAdd = {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result
|
||
}
|
||
return toAdd
|
||
} catch (err) {
|
||
console.error(err)
|
||
logToolOutcome('error')
|
||
const errorMessage = formatToolError(err)
|
||
toolCallbacks.setToolStatus(toolCall.id, {
|
||
isLoading: false,
|
||
isQueued: false,
|
||
isStreamingArguments: false,
|
||
error: errorMessage
|
||
})
|
||
return {
|
||
role: 'tool' as const,
|
||
tool_call_id: toolCall.id,
|
||
content: `Error while calling tool: ${errorMessage}`
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Flush images buffered by tools during a batch (via toolCallbacks.attachToolImage)
|
||
* as ONE follow-up user message, appended to both `messages` (sent on later
|
||
* iterations) and `addedMessages` (committed to history). Call this once per
|
||
* completion, right after the whole tool loop — never mid-batch, so every tool_call
|
||
* id is already answered by its tool result before this non-tool message. The image
|
||
* parts ride the same `image_url` carrier that the provider converters translate.
|
||
*/
|
||
/** The message that hands tool-produced images to the model, or undefined when
|
||
* there are none. Split out so a snapshot of a turn still buffering them can
|
||
* build the same message without draining the buffer the live turn still owns. */
|
||
export function pendingToolImagesMessage(
|
||
images: AttachedImage[]
|
||
): ChatCompletionMessageParam | undefined {
|
||
if (images.length === 0) return undefined
|
||
return {
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: 'Screenshot(s) of the app preview:' },
|
||
...images.map((img) => dataUrlToImagePart(img.dataUrl))
|
||
]
|
||
}
|
||
}
|
||
|
||
export function appendPendingToolImages(
|
||
messages: ChatCompletionMessageParam[],
|
||
addedMessages: ChatCompletionMessageParam[],
|
||
toolCallbacks: ToolCallbacks
|
||
): void {
|
||
const message = pendingToolImagesMessage(toolCallbacks.takePendingToolImages?.() ?? [])
|
||
if (!message) return
|
||
messages.push(message)
|
||
addedMessages.push(message)
|
||
}
|
||
|
||
export interface Tool<T> {
|
||
def: ChatCompletionFunctionTool
|
||
fn: (p: {
|
||
args: any
|
||
workspace: string
|
||
helpers: T
|
||
toolCallbacks: ToolCallbacks
|
||
toolId: string
|
||
}) => Promise<string>
|
||
preAction?: (p: { toolCallbacks: ToolCallbacks; toolId: string }) => void
|
||
/** Refuse the call before any confirmation is offered. A bare string is both the row the
|
||
* user reads and the result the model gets; return the pair when the model needs a steer
|
||
* too long to be a transcript row. */
|
||
validateBeforeConfirmation?: (p: {
|
||
args: any
|
||
workspace: string
|
||
helpers: T
|
||
}) => MaybePromise<ToolRejection | undefined>
|
||
/** This chat's definition of the tool, for a schema that depends on the chat (its workspace,
|
||
* its open flow). Returns a new def: tools are module singletons shared by every chat, so
|
||
* one written back leaks into the others. */
|
||
schemaFor?: (helpers: any) => Promise<ChatCompletionFunctionTool>
|
||
/** Safe to run while plan mode is active. Absence fails closed. */
|
||
planModeSafe?: boolean
|
||
/** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool
|
||
* that is admissible in general but not on every target. Consulted only once `planModeSafe`
|
||
* is true. */
|
||
refuseInPlanMode?: (p: { args: any; helpers: T }) => ToolRejection | undefined
|
||
requiresConfirmation?: boolean
|
||
/** The tool's own argument form is its confirmation, and the bypass posture answers that
|
||
* form — so no card is waited on, yet a decision is still being made for the user. The
|
||
* list of what the posture bypasses is built from both this and `requiresConfirmation`. */
|
||
bypassedByAutoAccept?: boolean
|
||
/** Header shown on the confirmation card before the tool runs. Pass a function
|
||
* to derive it from the parsed arguments (e.g. name the script being tested). */
|
||
confirmationMessage?: string | ((args: any) => string)
|
||
/** Only when a card gates the call, so `fn` must not rely on it. Not awaited, so it may
|
||
* not throw, and must be safe for a call the user then declines. */
|
||
onConfirmationRequested?: (p: { args: any; toolCallbacks: ToolCallbacks; toolId: string }) => void
|
||
/** Model-facing result returned when the user rejects the confirmation; defaults
|
||
* to a generic cancellation. */
|
||
cancellationMessage?: string
|
||
showDetails?: boolean
|
||
autoCollapseDetails?: boolean
|
||
streamArguments?: boolean
|
||
showFade?: boolean
|
||
/** Header shown while the model is still streaming this call's arguments,
|
||
* before `fn` runs and sets a real status. Defaults to "Preparing <name>...". */
|
||
streamingLabel?: string
|
||
/** Header shown while the call waits its turn to execute (args fully streamed).
|
||
* Pass a function to derive it from the parsed arguments (e.g. name the script
|
||
* about to run). Defaults to the humanized tool name ("run_script" → "Run script"). */
|
||
queuedLabel?: string | ((args: any) => string)
|
||
}
|
||
|
||
/** Status patch demoting a tool call to the queued state once its arguments have
|
||
* fully streamed: it waits its turn (tool calls in one message run sequentially)
|
||
* and processToolCall flips it back to loading when execution starts. The header
|
||
* switches from the "-ing" streaming label to an imperative one so a waiting call
|
||
* doesn't read as active. */
|
||
export function queuedToolStatus(
|
||
tools: Tool<any>[],
|
||
toolName: string,
|
||
argsString: string | undefined
|
||
): Partial<ToolDisplayMessage> {
|
||
const tool = tools.find((t) => t.def.function.name === toolName)
|
||
const words = toolName
|
||
.replaceAll('_', ' ')
|
||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||
.toLowerCase()
|
||
let content = words.charAt(0).toUpperCase() + words.slice(1)
|
||
if (typeof tool?.queuedLabel === 'string') {
|
||
content = tool.queuedLabel
|
||
} else if (typeof tool?.queuedLabel === 'function') {
|
||
try {
|
||
content = tool.queuedLabel(JSON.parse(argsString || '{}'))
|
||
} catch {
|
||
// Truncated/invalid args: keep the humanized name; the error path handles the rest.
|
||
}
|
||
}
|
||
return { isLoading: false, isQueued: true, isStreamingArguments: false, content }
|
||
}
|
||
|
||
/** Status of a job the chat started and tracks in the jobs tray. Mirrors the
|
||
* runs page: `suspended` = a flow step waiting for approval, `scheduled` = a run
|
||
* scheduled for later. Kept in lockstep with `ChatJob.job` (see below). */
|
||
export type ChatJobStatus =
|
||
| 'queued'
|
||
| 'running'
|
||
| 'suspended'
|
||
| 'scheduled'
|
||
| 'success'
|
||
| 'failure'
|
||
| 'canceled'
|
||
|
||
/** Serializable identity of a tool's terminal result formatter, stored on a ChatJob
|
||
* so a detached job that survives a reload can still reconstruct the shaped result
|
||
* its launching tool would have produced (see formatChatJobCompletion). A closure
|
||
* can't be persisted to IndexedDB; this discriminant can. */
|
||
export type ChatJobResultFormat = { kind: 'datatable'; datatableName: string }
|
||
|
||
/** A job the chat started and is tracking. Rendered in the jobs tray, persisted
|
||
* with the chat, and advanced by a single background poller on the manager. */
|
||
export type ChatJob = {
|
||
jobId: string
|
||
/** Pairs with the ToolDisplayMessage card that launched it. */
|
||
toolCallId: string
|
||
kind: 'script' | 'flow'
|
||
/** Path or step label shown in the tray row. */
|
||
label: string
|
||
workspace: string
|
||
createdAt: number
|
||
status: ChatJobStatus
|
||
durationMs?: number
|
||
/** True once it left the inline wait and is polled in the background. */
|
||
detached: boolean
|
||
/** Notify-only: whether its completion has been surfaced to the model yet. */
|
||
reported: boolean
|
||
/** Whether the user saw its terminal status in the jobs popover. Reviewed
|
||
* outcomes stop driving the segment chip's status readout. Persisted. */
|
||
reviewed?: boolean
|
||
/** Trimmed snapshot of the last fetched Job (heavy fields stripped, see
|
||
* `trimJob`), fed to `<JobStatusIcon>` so the tray badge matches the runs page
|
||
* exactly. Always written together with `status` from the SAME job so the two
|
||
* can't drift. Undefined only before the first fetch. */
|
||
job?: Job
|
||
/** Set by tools that shape their result (e.g. exec_datatable_sql). Persisted, so
|
||
* a detached job that finishes after a reload still reports through the tool's
|
||
* result contract rather than generic job output. */
|
||
resultFormat?: ChatJobResultFormat
|
||
}
|
||
|
||
/** Derive the tray status from a fetched Job. Deliberately mirrors the branch
|
||
* order of JobStatusIcon.svelte so the scalar status and the badge never
|
||
* disagree. */
|
||
export function deriveChatJobStatus(job: Job): ChatJobStatus {
|
||
if ('success' in job) {
|
||
return job.canceled ? 'canceled' : job.success ? 'success' : 'failure'
|
||
}
|
||
// QueuedJob
|
||
if (job.running && job.suspend) return 'suspended'
|
||
if (job.running) return 'running'
|
||
if (job.scheduled_for && forLater(job.scheduled_for)) return 'scheduled'
|
||
return 'queued'
|
||
}
|
||
|
||
/** Strip the heavy fields from a fetched Job before storing it on a ChatJob (the
|
||
* tray only needs the status-discriminant scalars JobStatusIcon reads).
|
||
*
|
||
* MUST clone + delete — never rebuild as an object literal. JobStatusIcon
|
||
* discriminates with the `in` operator (`'success' in job`), which tests KEY
|
||
* PRESENCE, not truthiness. A literal that always carries a `success` key would
|
||
* make every running/queued job misrender as a completed (failed) job. */
|
||
export function trimJob(job: Job): Job {
|
||
const trimmed = { ...job } as Record<string, unknown>
|
||
delete trimmed.logs
|
||
delete trimmed.args
|
||
delete trimmed.result
|
||
delete trimmed.raw_code
|
||
delete trimmed.raw_flow
|
||
delete trimmed.flow_status
|
||
return trimmed as unknown as Job
|
||
}
|
||
|
||
/** The subset supplied when a job first starts; the manager fills in the rest. */
|
||
export type ChatJobInit = Pick<
|
||
ChatJob,
|
||
'jobId' | 'toolCallId' | 'kind' | 'label' | 'workspace' | 'resultFormat'
|
||
>
|
||
|
||
export interface ToolCallbacks {
|
||
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
|
||
removeToolStatus: (id: string) => void
|
||
/** Job-tracking hooks, wired only by the global/sessions chat (mode === GLOBAL).
|
||
* Their presence is what enables detach-into-background in executeTestRun; when
|
||
* absent (in-editor script/flow/pipeline chats), test runs stay blocking with a
|
||
* 60s cap. */
|
||
onJobStarted?: (job: ChatJobInit) => void
|
||
onJobStatus?: (jobId: string, update: Partial<ChatJob>) => void
|
||
onJobDetached?: (jobId: string) => void
|
||
/** Streamed reasoning/thinking deltas, rendered as a collapsible block in the chat. */
|
||
onReasoningDelta?: (token: string) => void
|
||
/** Fired when the model starts reasoning — drives a "Thinking" indicator even when
|
||
* no summary text is returned (e.g. OpenAI reasoning models). */
|
||
onReasoningStart?: () => void
|
||
requestConfirmation?: (toolId: string, toolName?: string) => Promise<boolean>
|
||
shouldAutoAcceptToolConfirmations?: (toolName?: string) => boolean
|
||
isPlanModeActive?: () => boolean
|
||
onToolBlockedByPlanMode?: () => void
|
||
requestUserQuestion?: (
|
||
toolId: string,
|
||
question: UserQuestionDisplay
|
||
) => Promise<string[] | undefined>
|
||
/** Park the loop on an argument form and resolve with the args the user submitted, or
|
||
* undefined if they cancelled. Wired only where the form can be rendered. `autoAccepted`
|
||
* says YOLO already answered it with what it opened with, so there is no card to wait on. */
|
||
requestRunArgs?: (
|
||
toolId: string,
|
||
form: RunFormDisplay,
|
||
opts?: { autoAccepted?: boolean }
|
||
) => Promise<Record<string, any> | undefined>
|
||
/** The submitted form's job is queued. Wired alongside requestRunArgs. */
|
||
markRunFormStarted?: (toolId: string) => void
|
||
/** Records a workspace item the tool call created/edited/deleted, by its
|
||
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
|
||
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
|
||
onItemModified?: (itemKind: UserDraftItemKind, storagePath: string) => void
|
||
/** A tool deployed a draft: the mask entry moves from the draft's storage path
|
||
* to the deployed path (they differ for synthetic draft-only storage keys). */
|
||
onItemDeployed?: (itemKind: UserDraftItemKind, storagePath: string, deployedPath: string) => void
|
||
/** A tool discarded a draft: the chat's touch on the item is undone. */
|
||
onItemDiscarded?: (itemKind: UserDraftItemKind, storagePath: string) => void
|
||
/**
|
||
* Buffer an image a tool produced (e.g. take_screenshot). Tool results are
|
||
* string-only and OpenAI forbids images in tool messages, so buffered images are
|
||
* flushed as a follow-up user message once the whole tool batch is answered (see
|
||
* appendPendingToolImages) — appending mid-batch would leave sibling tool_call ids
|
||
* unanswered before a non-tool message.
|
||
*/
|
||
attachToolImage?: (toolId: string, image: AttachedImage) => void
|
||
/** Drain every image buffered this batch (insertion order), clearing the buffer. */
|
||
takePendingToolImages?: () => AttachedImage[]
|
||
/** Wired only by the global/sessions chat: delivers the folder instructions
|
||
* covering the paths a tool call names, once per conversation. */
|
||
folderInstructions?: FolderInstructionsContext
|
||
}
|
||
|
||
export function createToolDef(
|
||
zodSchema: z.ZodSchema,
|
||
name: string,
|
||
description: string,
|
||
{ strict = true }: { strict?: boolean } = {} // we sometimes have to set strict to false for open ai models to avoid issues with complex properties
|
||
): ChatCompletionFunctionTool {
|
||
// console.log('creating tool def for', name, zodSchema)
|
||
let parameters = z.toJSONSchema(zodSchema)
|
||
delete parameters.$schema
|
||
if (!parameters.required) parameters.required = []
|
||
normalizeToolParameterSchema(parameters)
|
||
const effectiveStrict = strict && !hasOptionalProperties(parameters)
|
||
|
||
return {
|
||
type: 'function',
|
||
function: {
|
||
strict: effectiveStrict,
|
||
name,
|
||
description,
|
||
parameters
|
||
}
|
||
}
|
||
}
|
||
|
||
function hasOptionalProperties(schema: Record<string, any> | undefined): boolean {
|
||
if (!schema || typeof schema !== 'object') {
|
||
return false
|
||
}
|
||
|
||
if (schema.properties && typeof schema.properties === 'object') {
|
||
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
||
const propertyKeys = Object.keys(schema.properties)
|
||
if (propertyKeys.some((key) => !required.has(key))) {
|
||
return true
|
||
}
|
||
for (const key of propertyKeys) {
|
||
if (hasOptionalProperties(schema.properties[key])) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
if (schema.items) {
|
||
if (Array.isArray(schema.items)) {
|
||
if (schema.items.some((item) => hasOptionalProperties(item))) {
|
||
return true
|
||
}
|
||
} else if (hasOptionalProperties(schema.items)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if (
|
||
schema.additionalProperties &&
|
||
typeof schema.additionalProperties === 'object' &&
|
||
hasOptionalProperties(schema.additionalProperties)
|
||
) {
|
||
return true
|
||
}
|
||
|
||
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
||
if (
|
||
Array.isArray(schema[key]) &&
|
||
schema[key].some((subSchema: Record<string, any>) => hasOptionalProperties(subSchema))
|
||
) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
const searchHubScriptsSchema = z.object({
|
||
query: z
|
||
.string()
|
||
.describe('The query to search for, e.g. send email, list stripe invoices, etc..')
|
||
})
|
||
|
||
const searchHubScriptsToolDef = createToolDef(
|
||
searchHubScriptsSchema,
|
||
'search_hub_scripts',
|
||
'Search for scripts in the hub'
|
||
)
|
||
|
||
/** The hub resolves a script by its version id alone; the app and summary
|
||
* segments are descriptive only. Mirrors the paths the hub pickers build. */
|
||
function hubScriptPath(s: { version_id: number; app: string; summary: string }): string {
|
||
return `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
|
||
}
|
||
|
||
/** Hub scripts are hosted outside the workspace: their paths resolve through the
|
||
* hub endpoints only, never through workspace lookups or drafts. */
|
||
export function isHubPath(path: string): boolean {
|
||
return path.startsWith('hub/')
|
||
}
|
||
|
||
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
|
||
requires: NONE,
|
||
def: searchHubScriptsToolDef,
|
||
planModeSafe: true,
|
||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: 'Searching for hub scripts related to "' + args.query + '"...'
|
||
})
|
||
const parsedArgs = searchHubScriptsSchema.parse(args)
|
||
const scripts = await ScriptService.queryHubScripts({
|
||
text: parsedArgs.query,
|
||
kind: 'script'
|
||
})
|
||
// Each result costs a content fetch, so cap the fan-out when content is wanted.
|
||
const matches = withContent ? scripts.slice(0, 3) : scripts
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Found ${matches.length} script${matches.length === 1 ? '' : 's'} in the hub related to "${parsedArgs.query}"`
|
||
})
|
||
const results = await Promise.all(
|
||
matches.map(async (s) => {
|
||
const path = hubScriptPath(s)
|
||
if (!withContent) {
|
||
return { path, summary: s.summary }
|
||
}
|
||
// The content fetch, not the listing above: these are the few candidates
|
||
// the AI pulled to choose between, which is the closest signal we have.
|
||
logHubScriptPick(s, 'ai')
|
||
try {
|
||
// get_full, not the raw content endpoint: callers are told to match the
|
||
// script's language, which raw content does not carry.
|
||
const hub = await ScriptService.getHubScriptByPath({ path })
|
||
return { path, summary: s.summary, language: hub.language, content: hub.content }
|
||
} catch (err) {
|
||
// One unreachable script must not sink the whole search.
|
||
return {
|
||
path,
|
||
summary: s.summary,
|
||
error: `Could not fetch content: ${err instanceof Error ? err.message : String(err)}`
|
||
}
|
||
}
|
||
})
|
||
)
|
||
return JSON.stringify(results)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Recursively normalizes JSON Schema quirks that specific providers reject.
|
||
*/
|
||
function normalizeToolParameterSchema(schema: Record<string, any> | undefined): void {
|
||
if (!schema || typeof schema !== 'object') {
|
||
return
|
||
}
|
||
|
||
// Remove format if it's null or empty string
|
||
if (schema.format === null || schema.format === '') {
|
||
delete schema.format
|
||
}
|
||
|
||
// Recurse into properties
|
||
if (schema.properties && typeof schema.properties === 'object') {
|
||
for (const key of Object.keys(schema.properties)) {
|
||
normalizeToolParameterSchema(schema.properties[key])
|
||
}
|
||
}
|
||
|
||
// Recurse into items (for arrays)
|
||
if (schema.items) {
|
||
if (Array.isArray(schema.items)) {
|
||
for (const item of schema.items) {
|
||
normalizeToolParameterSchema(item)
|
||
}
|
||
} else {
|
||
normalizeToolParameterSchema(schema.items)
|
||
}
|
||
}
|
||
|
||
// Recurse into additionalProperties if it's an object schema
|
||
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
||
normalizeToolParameterSchema(schema.additionalProperties)
|
||
}
|
||
|
||
// Recurse into allOf, anyOf, oneOf
|
||
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
||
if (Array.isArray(schema[key])) {
|
||
for (const subSchema of schema[key]) {
|
||
normalizeToolParameterSchema(subSchema)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function buildSchemaForTool(
|
||
toolDef: ChatCompletionFunctionTool,
|
||
schemaBuilder: () => Promise<FunctionParameters>
|
||
): Promise<boolean> {
|
||
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(', ')}`)
|
||
}
|
||
|
||
// Anthropic requires input_schema.type to be present; flows with no inputs
|
||
// can produce a sparse schema (e.g. { order: [] }) lacking it.
|
||
toolDef.function.parameters = { type: 'object', ...schema, additionalProperties: false }
|
||
|
||
// recursively normalize provider-incompatible schema fragments
|
||
normalizeToolParameterSchema(toolDef.function.parameters)
|
||
|
||
// OPEN AI models don't support strict mode well with schema with complex properties, so we disable it
|
||
const model = getCurrentModel()
|
||
if (
|
||
model.provider === 'openai' ||
|
||
model.provider === 'azure_openai' ||
|
||
model.provider === 'azure_foundry'
|
||
) {
|
||
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
|
||
export const MAX_LOG_LENGTH = 4000
|
||
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
||
|
||
/** How long a test run is awaited inline before it detaches into the background
|
||
* (global/sessions chat only). Quick runs finish well inside this; slow ones are
|
||
* handed to the background poller so the chat loop is freed. */
|
||
export const DETACH_AFTER_MS = 15000
|
||
|
||
/** Upper bound on a model-requested inline wait. Beyond this, backgrounding is
|
||
* almost always better than holding the chat turn, so we clamp rather than let
|
||
* the model block the loop for minutes. */
|
||
export const MAX_DETACH_AFTER_MS = 120000
|
||
|
||
export interface TestRunConfig {
|
||
jobStarter: () => Promise<string>
|
||
workspace: string
|
||
toolCallbacks: ToolCallbacks
|
||
toolId: string
|
||
startMessage?: string
|
||
contextName: 'script' | 'flow'
|
||
/** Detach immediately instead of waiting the inline budget (the model's opt-in). */
|
||
background?: boolean
|
||
/** Overrides the inline wait budget (ms) before the job detaches into the tray.
|
||
* The model's opt-in for jobs it expects to take a bit longer than the 15s
|
||
* default but still wants to await in-turn. Ignored when `background` is set
|
||
* (that detaches immediately). Clamped to MAX_DETACH_AFTER_MS. */
|
||
detachAfterMs?: number
|
||
/** Human label for the jobs tray row (path / step id). Defaults to the job id. */
|
||
label?: string
|
||
/** Overrides the default "…started, waiting for completion" status while the
|
||
* job runs inline (e.g. an SQL tool shows "SQL running…"). */
|
||
runningMessage?: string
|
||
/** The item noun in the human-facing status strings ("Flow test completed
|
||
* successfully"). Defaults to `contextName`, which also carries the jobs-tray kind
|
||
* and so cannot always name what ran: an app's path runnable queues a flow job. */
|
||
completionName?: string
|
||
/** The action noun in those same strings ("Script run completed successfully").
|
||
* Defaults to "test", so a tool running the deployed item for real passes "run". */
|
||
actionNoun?: string
|
||
/** Custom terminal formatting for the INLINE completion path (callers whose
|
||
* result isn't a plain test-run summary, e.g. exec_datatable_sql shaping rows).
|
||
* Returns the string handed to the model plus the tool-card patch. When omitted,
|
||
* the default summary is used. For the DETACHED/rehydrated path, supply
|
||
* `resultFormat` too so the completion can be reconstructed without this closure. */
|
||
formatCompletion?: BackgroundJobFormatter
|
||
/** Serializable twin of `formatCompletion`, stored on the ChatJob so a detached
|
||
* job that finishes after a reload still reports through the tool's result
|
||
* contract (see AIChatManager.#onBackgroundJobComplete). */
|
||
resultFormat?: ChatJobResultFormat
|
||
}
|
||
|
||
/** Terminal formatter a tool supplies so its result keeps the same model-visible
|
||
* contract whether the job finishes inline or completes after detaching into the
|
||
* background. */
|
||
export type BackgroundJobFormatter = (job: CompletedJob) => {
|
||
llmText: string
|
||
card: Partial<ToolDisplayMessage>
|
||
}
|
||
|
||
/** Reads a running job's output incrementally through `getJobUpdates`, the only endpoint
|
||
* carrying `new_result_stream` — `getJob` returns logs but never the partial result. Each
|
||
* reader keeps its own offsets, so one starting over refetches from zero. Best-effort: a
|
||
* failed poll answers `undefined` and mutates nothing, so the next resumes from the same
|
||
* offsets and a run always lands on `getJob` alone. */
|
||
export function createJobUpdateReader(jobId: string, workspace: string) {
|
||
let logs = ''
|
||
let resultStream = ''
|
||
let logOffset = 0
|
||
let streamOffset = 0
|
||
let started = false
|
||
return {
|
||
async poll(): Promise<{ completed: boolean; logs: string; resultStream: string } | undefined> {
|
||
let update: Awaited<ReturnType<typeof JobService.getJobUpdates>>
|
||
try {
|
||
update = await JobService.getJobUpdates({
|
||
workspace,
|
||
id: jobId,
|
||
running: started,
|
||
logOffset,
|
||
streamOffset
|
||
})
|
||
} catch {
|
||
return undefined
|
||
}
|
||
started ||= update.running ?? false
|
||
// Both kept as a tail: the offsets come from the server, so dropping the head
|
||
// costs nothing here, and neither is the record of the run — the logs are on the
|
||
// job, and a streamed partial is replaced by the result the moment it lands.
|
||
if (update.new_logs) logs = (logs + update.new_logs).slice(-MAX_LOG_LENGTH)
|
||
if (update.new_result_stream) {
|
||
resultStream = (resultStream + update.new_result_stream).slice(-MAX_LOG_LENGTH)
|
||
}
|
||
if (update.log_offset) logOffset = update.log_offset
|
||
if (update.stream_offset) streamOffset = update.stream_offset
|
||
return { completed: update.completed ?? false, logs, resultStream }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Common job polling function.
|
||
//
|
||
// Two modes, selected by whether `detachAfterMs` is provided:
|
||
// - Blocking (undefined): poll up to 60×1s, then set a timeout error and throw.
|
||
// Used by in-editor chats, which have no jobs tray to hand off to.
|
||
// - Detach (a number): poll only for that inline budget; if the job is still
|
||
// running when it elapses, resolve `'detached'` instead of throwing so the
|
||
// caller can background the job. `0` detaches without polling at all.
|
||
export async function pollJobCompletion(
|
||
jobId: string,
|
||
workspace: string,
|
||
toolId: string,
|
||
toolCallbacks: ToolCallbacks,
|
||
options?: { detachAfterMs?: number }
|
||
): Promise<CompletedJob | 'detached'> {
|
||
const detachEnabled = options?.detachAfterMs !== undefined
|
||
const maxAttempts = detachEnabled ? Math.ceil((options?.detachAfterMs ?? 0) / 1000) : 60
|
||
let attempts = 0
|
||
let job: CompletedJob | null = null
|
||
const reader = createJobUpdateReader(jobId, workspace)
|
||
|
||
while (attempts < maxAttempts) {
|
||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||
attempts++
|
||
|
||
try {
|
||
const update = await reader.poll()
|
||
// The tray's snapshot is trimmed of logs (it is persisted), so the card is the
|
||
// only place a running job's output can land. Cards that hide their logs while
|
||
// loading are unaffected; the run card follows them line by line.
|
||
if (update) {
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
logs: formatLogs(update.logs),
|
||
resultStream: update.resultStream || undefined
|
||
})
|
||
}
|
||
|
||
// Ask for the logs when the run may be over — the tail written between the last
|
||
// poll and the end is only on the job itself — or when there is no reader output
|
||
// to have collected them.
|
||
const wantLogs = !update || update.completed
|
||
const fetchedJob = await JobService.getJob({
|
||
workspace: workspace,
|
||
id: jobId,
|
||
noLogs: !wantLogs,
|
||
noCode: true
|
||
})
|
||
if (fetchedJob.type === 'CompletedJob') {
|
||
// The updates can still call a landed job unfinished, so a completion seen on
|
||
// a logless fetch is fetched again rather than settled without them: the model
|
||
// reads these logs, and their absence is indistinguishable from a silent run.
|
||
job = wantLogs
|
||
? fetchedJob
|
||
: ((await JobService.getJob({
|
||
workspace: workspace,
|
||
id: jobId,
|
||
noLogs: false,
|
||
noCode: true
|
||
})) as CompletedJob)
|
||
break
|
||
}
|
||
// With no reader, this is the only place the card's logs can come from.
|
||
if (!update) {
|
||
toolCallbacks.setToolStatus(toolId, { logs: formatLogs(fetchedJob.logs) })
|
||
}
|
||
// The badge needs the real Job to tell running from suspended or scheduled, which
|
||
// the updates do not say.
|
||
toolCallbacks.onJobStatus?.(jobId, {
|
||
status: deriveChatJobStatus(fetchedJob),
|
||
job: trimJob(fetchedJob)
|
||
})
|
||
} catch (error) {
|
||
if (!detachEnabled && attempts >= maxAttempts) {
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!job) {
|
||
if (detachEnabled) {
|
||
return 'detached'
|
||
}
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: 'Test timed out',
|
||
error: 'Execution timed out or failed to complete'
|
||
})
|
||
throw new Error('Test execution timed out after 60 seconds')
|
||
}
|
||
|
||
return job
|
||
}
|
||
|
||
// Helper function to extract code blocks from markdown text
|
||
export function extractCodeFromMarkdown(markdown: string): string[] {
|
||
const codeBlocks: string[] = []
|
||
|
||
// Matches: ```[language]\n[code]\n```
|
||
const codeBlockRegex = /```(?:[a-z]+)?\n([\s\S]*?)```/g
|
||
|
||
let match: RegExpExecArray | null = null
|
||
while ((match = codeBlockRegex.exec(markdown)) !== null) {
|
||
const code = match[1].trim()
|
||
if (code) {
|
||
codeBlocks.push(code)
|
||
}
|
||
}
|
||
|
||
return codeBlocks
|
||
}
|
||
|
||
// Helper function to get the latest assistant message from display messages
|
||
export function getLatestAssistantMessage(displayMessages: DisplayMessage[]): string | undefined {
|
||
// Iterate from the end to find the most recent assistant message
|
||
for (let i = displayMessages.length - 1; i >= 0; i--) {
|
||
const message = displayMessages[i]
|
||
if (message.role === 'assistant' && message.content) {
|
||
return message.content
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
// Helper function to extract error messages from job results
|
||
function getErrorMessage(result: unknown): string {
|
||
if (typeof result === 'object' && result !== null && 'error' in result) {
|
||
const error = (result as Record<string, unknown>).error
|
||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||
const message = (error as Record<string, unknown>).message as string
|
||
if ('stack' in error) {
|
||
return (message + '\n' + (error as Record<string, unknown>).stack) as string
|
||
}
|
||
return message
|
||
}
|
||
if (typeof error === 'string') {
|
||
return error
|
||
}
|
||
}
|
||
if (typeof result === 'string') {
|
||
return result
|
||
}
|
||
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: ChatCompletionFunctionTool
|
||
): Promise<any> {
|
||
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
|
||
}
|
||
|
||
// Said with the result rather than in the system prompt: there, models still copied a
|
||
// result's table or JSON into the reply, under a card that already renders it. Only for
|
||
// a successful run in the generic card completedJobToolStatus fills: a failed run's card
|
||
// lands on the error with the logs a tab away, and a formatCompletion card shows its own.
|
||
// Never on a background completion: its card can be far up the chat by then, so the
|
||
// reply may be the only place the user reads the result.
|
||
const RESULT_SHOWN_NOTE =
|
||
"The user already sees this run's result in its card. Do not repeat the result or logs in your reply: say what it shows, quoting only the values your conclusion rests on."
|
||
|
||
// The string handed back to the model when a job is backgrounded. It carries the
|
||
// job id so the model can pull status/args/result/logs on demand (get_run / list_runs),
|
||
// and tells it the completion will be reported later (notify-only wake).
|
||
function backgroundedSummary(jobId: string, label: string): string {
|
||
return (
|
||
`Job ${jobId} for "${label}" is taking a while and is now running in the background — ` +
|
||
`the chat is free to continue and you'll be told when it finishes. ` +
|
||
`To inspect it now, call get_run with id="${jobId}" (or list_runs); ` +
|
||
`to stop it, call cancel_job with id="${jobId}".`
|
||
)
|
||
}
|
||
|
||
// Tool-card status patch for a completed background job. Mirrors the inline
|
||
// terminal branch of executeTestRun so a job that finished in the background
|
||
// fills its card the same way one that finished inline does.
|
||
export function completedJobToolStatus(job: CompletedJob): Partial<ToolDisplayMessage> {
|
||
// A canceled job isn't a `success`, but it isn't a failure either — the user
|
||
// stopped it — so don't dress the card as an error. It still has the result the run
|
||
// page shows for a canceled run, which names who stopped it, so keep that.
|
||
if (job.canceled) {
|
||
return {
|
||
content: 'Background job canceled',
|
||
result: formatResult(job.result),
|
||
logs: formatLogs(job.logs),
|
||
resultStream: undefined
|
||
}
|
||
}
|
||
return {
|
||
content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,
|
||
result: formatResult(job.result),
|
||
logs: formatLogs(job.logs),
|
||
// The partial is the result now, so nothing streamed is kept beside it.
|
||
resultStream: undefined,
|
||
...(job.success ? {} : { error: getErrorMessage(job.result) })
|
||
}
|
||
}
|
||
|
||
// Short completion note handed to the model on its next turn (notify-only wake).
|
||
// Carries the id so the model can pull the args, result and logs via get_run on demand.
|
||
export function backgroundJobCompletionNote(
|
||
jobId: string,
|
||
label: string,
|
||
job: CompletedJob,
|
||
// When the launching tool supplied a formatter (e.g. exec_datatable_sql), pass its
|
||
// `llmText` here so the notify-only note carries the same shaped result the inline
|
||
// path would have returned — row-capped, friendly errors — instead of the raw job
|
||
// result. Omitted → the generic 2000-char result head.
|
||
formattedResult?: string
|
||
): string {
|
||
const status = job.success ? 'succeeded' : 'FAILED'
|
||
const resultHead = formattedResult ?? formatResult(job.result).slice(0, 2000)
|
||
const flowHint =
|
||
!job.success && (job.job_kind === 'flow' || job.job_kind === 'flowpreview')
|
||
? ` For per-step statuses and results call get_run with id="${jobId}".`
|
||
: ''
|
||
return (
|
||
`Background job ${jobId} for "${label}" ${status}.\n` +
|
||
`Result: ${resultHead}\n` +
|
||
`(For the args, result and logs call get_run with id="${jobId}".${flowHint})`
|
||
)
|
||
}
|
||
|
||
export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||
// Detach-into-background is enabled only when the host wired the job hooks
|
||
// (global/sessions chat). Otherwise this stays a blocking call.
|
||
const detachEnabled = !!config.toolCallbacks.onJobStarted
|
||
const label = config.label ?? config.contextName
|
||
const actionNoun = config.actionNoun ?? 'test'
|
||
// Stands on its own where the status strings are prefixed by the item, so its
|
||
// default carries the noun.
|
||
const failureNoun = config.actionNoun ?? 'test run'
|
||
try {
|
||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||
content: config.startMessage || `Starting ${config.contextName} test...`
|
||
})
|
||
|
||
const jobId = await config.jobStarter()
|
||
|
||
const shown = config.completionName ?? config.contextName
|
||
const contextName = shown.charAt(0).toUpperCase() + shown.slice(1)
|
||
|
||
// Register the job so the tray shows it from the moment it is queued. Carry the
|
||
// serializable resultFormat so a job that later detaches (and may outlive a
|
||
// reload) can reconstruct the same model-visible contract this inline path
|
||
// applies below.
|
||
config.toolCallbacks.onJobStarted?.({
|
||
jobId,
|
||
toolCallId: config.toolId,
|
||
kind: config.contextName,
|
||
label,
|
||
workspace: config.workspace,
|
||
resultFormat: config.resultFormat
|
||
})
|
||
|
||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||
content:
|
||
config.runningMessage ?? `${contextName} ${actionNoun} started, waiting for completion...`
|
||
})
|
||
|
||
const outcome = await pollJobCompletion(
|
||
jobId,
|
||
config.workspace,
|
||
config.toolId,
|
||
config.toolCallbacks,
|
||
detachEnabled
|
||
? {
|
||
detachAfterMs: config.background
|
||
? 0
|
||
: Math.min(config.detachAfterMs ?? DETACH_AFTER_MS, MAX_DETACH_AFTER_MS)
|
||
}
|
||
: undefined
|
||
)
|
||
|
||
if (outcome === 'detached') {
|
||
config.toolCallbacks.onJobDetached?.(jobId)
|
||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||
content: `${contextName} ${actionNoun} running in background (job ${jobId})`
|
||
})
|
||
return backgroundedSummary(jobId, label)
|
||
}
|
||
|
||
const job = outcome
|
||
config.toolCallbacks.onJobStatus?.(jobId, {
|
||
status: deriveChatJobStatus(job),
|
||
durationMs: job.duration_ms,
|
||
job: trimJob(job)
|
||
})
|
||
|
||
if (config.formatCompletion) {
|
||
const { llmText, card } = config.formatCompletion(job)
|
||
config.toolCallbacks.setToolStatus(config.toolId, card)
|
||
return llmText
|
||
}
|
||
|
||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||
content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`,
|
||
result: formatResult(job.result),
|
||
logs: formatLogs(job.logs),
|
||
// The partial is the result now, so the card reads it off `result` alone and the
|
||
// transcript stops carrying a second copy of a streamed answer.
|
||
resultStream: undefined,
|
||
...(job.success ? {} : { error: getErrorMessage(job.result) })
|
||
})
|
||
|
||
// detachEnabled marks the global/sessions chat; the in-editor chats read the summary
|
||
// alone. The card opening on the result is up to each tool: a run card does, a
|
||
// generic one only with `autoCollapseDetails: false` at its registration.
|
||
const summary =
|
||
(detachEnabled && job.success ? `${RESULT_SHOWN_NOTE}\n` : '') +
|
||
formatResultSummary(job.result, job.logs, job.success)
|
||
// get_run only exists in the global/sessions chat (the same hosts that wire
|
||
// the job hooks) — don't advertise it to in-editor chats.
|
||
if (detachEnabled && config.contextName === 'flow' && !job.success) {
|
||
return (
|
||
summary +
|
||
`\n\nFor per-step statuses and results (subflow steps included), call get_run with id="${jobId}".`
|
||
)
|
||
}
|
||
return summary
|
||
} catch (error) {
|
||
// formatToolError, not `error.message`: the generated client puts the server's
|
||
// message in `body` and leaves `message` as the bare status text, so a path
|
||
// runnable aimed at an undeployed flow reported "Not Found" instead of naming
|
||
// the flow it could not find — losing the one diagnostic the run exists for.
|
||
const errorMessage = formatToolError(error)
|
||
config.toolCallbacks.setToolStatus(config.toolId, {
|
||
content: `Execution failed`,
|
||
error: errorMessage
|
||
})
|
||
throw new Error(`Failed to execute ${failureNoun}: ${errorMessage}`)
|
||
}
|
||
}
|
||
|
||
type FlowStepScriptLoader = (
|
||
moduleValue: { path: string; hash?: string },
|
||
workspace: string
|
||
) => Promise<{ content: string; language: ScriptLang; schema?: Record<string, any> }>
|
||
|
||
/** A subflow step's target. `previewValue` is set only when a draft exists — that is what
|
||
* decides between previewing the draft and running the deployed flow by path — while
|
||
* `schema` describes whichever of the two is about to run. */
|
||
type FlowStepSubflowLoader = (
|
||
path: string,
|
||
workspace: string
|
||
) => Promise<{ previewValue?: FlowValue; schema?: Record<string, any> } | undefined>
|
||
|
||
type FlowStepRunConfig = {
|
||
flowValue: FlowValue
|
||
stepId: string
|
||
workspace: string
|
||
toolCallbacks: ToolCallbacks
|
||
toolId: string
|
||
loadScript?: FlowStepScriptLoader
|
||
loadSubflow?: FlowStepSubflowLoader
|
||
/** The path of the flow the step belongs to. An agent step's preview carries it, as the flow
|
||
* editor's own step test does: the run is filed under it, and a managed memory keyed on a
|
||
* memory id someone named reads the flow's conversation rather than a stray one. */
|
||
flowPath?: string
|
||
/** The flow editor's substitution of a linked agent's unsaved draft, passed in because it
|
||
* reaches the draft store, which this module's import list must not. */
|
||
withAgentDrafts?: (value: FlowValue) => Promise<FlowValue>
|
||
}
|
||
|
||
export type FlowStepTestRunConfig = FlowStepRunConfig & {
|
||
args?: Record<string, any> | null
|
||
background?: boolean
|
||
/** Inline wait budget (ms) before the step job detaches into the tray; forwarded
|
||
* to executeTestRun. Ignored when `background` is set. */
|
||
detachAfterMs?: number
|
||
}
|
||
|
||
/** One step resolved to the job it would start, short of starting it, so a caller that
|
||
* puts an argument form in front of the run can build the form's fields from the same
|
||
* read the job uses. Schema inference lives with the caller: this module is kept on a
|
||
* shallow import list (see the note at the top of the file). */
|
||
export type ResolvedFlowStepRun = {
|
||
module: FlowModule
|
||
runnableKind: 'script' | 'flow'
|
||
/** A subflow step carries `schema` instead, having no code of its own to read. */
|
||
code?: string
|
||
lang?: ScriptLang
|
||
schema?: Record<string, any>
|
||
startMessage: string
|
||
/** Takes the arguments as submitted. The preprocessor's entrypoint override is added
|
||
* here rather than by the caller: it is declared by no schema, so anything that
|
||
* conforms arguments to one would drop it. An agent step also takes the run form's rows:
|
||
* each runs from its submitted value, including one the submission leaves out. */
|
||
startJob: (args: Record<string, any>, formKeys?: readonly string[]) => Promise<string>
|
||
}
|
||
|
||
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
|
||
return args ?? {}
|
||
}
|
||
|
||
function flowStepArgsForModule(moduleId: string, args: Record<string, any>): Record<string, any> {
|
||
return moduleId === SPECIAL_MODULE_IDS.PREPROCESSOR
|
||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args }
|
||
: args
|
||
}
|
||
|
||
function getAvailableFlowStepIds(flowValue: FlowValue): string {
|
||
return Array.from(
|
||
new Set([
|
||
...extractAllModules(flowValue.modules ?? []).map((module: FlowModule) => module.id),
|
||
...(flowValue.preprocessor_module ? [flowValue.preprocessor_module.id] : []),
|
||
...(flowValue.failure_module ? [flowValue.failure_module.id] : [])
|
||
])
|
||
).join(', ')
|
||
}
|
||
|
||
async function loadDeployedScriptForFlowStep(
|
||
moduleValue: { path: string; hash?: string },
|
||
workspace: string
|
||
): Promise<{ content: string; language: ScriptLang; schema?: Record<string, any> }> {
|
||
const script = moduleValue.hash
|
||
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
|
||
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
|
||
return {
|
||
content: script.content,
|
||
language: script.language,
|
||
schema: script.schema as Record<string, any> | undefined
|
||
}
|
||
}
|
||
|
||
export async function resolveFlowStepRun({
|
||
flowValue,
|
||
stepId,
|
||
workspace,
|
||
toolCallbacks,
|
||
toolId,
|
||
loadScript = loadDeployedScriptForFlowStep,
|
||
loadSubflow,
|
||
flowPath,
|
||
withAgentDrafts
|
||
}: FlowStepRunConfig): Promise<ResolvedFlowStepRun> {
|
||
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
|
||
|
||
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: ${getAvailableFlowStepIds(flowValue)}`
|
||
)
|
||
}
|
||
|
||
const moduleValue = targetModule.value
|
||
const withEntrypoint = (args: Record<string, any>) => flowStepArgsForModule(targetModule.id, args)
|
||
|
||
if (moduleValue.type === 'rawscript') {
|
||
return {
|
||
module: targetModule,
|
||
runnableKind: 'script',
|
||
code: moduleValue.content ?? '',
|
||
lang: moduleValue.language,
|
||
startMessage: `Starting test run of step "${stepId}"...`,
|
||
startJob: (args) =>
|
||
JobService.runScriptPreview({
|
||
workspace,
|
||
requestBody: {
|
||
content: moduleValue.content ?? '',
|
||
language: moduleValue.language,
|
||
args: withEntrypoint(args)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
if (moduleValue.type === 'script') {
|
||
const script = await loadScript(moduleValue, workspace)
|
||
return {
|
||
module: targetModule,
|
||
runnableKind: 'script',
|
||
code: script.content,
|
||
lang: script.language,
|
||
schema: script.schema,
|
||
startMessage: `Starting test run of script step "${stepId}"...`,
|
||
startJob: (args) =>
|
||
JobService.runScriptPreview({
|
||
workspace,
|
||
requestBody: {
|
||
path: moduleValue.path,
|
||
content: script.content,
|
||
language: script.language,
|
||
args: withEntrypoint(args)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
if (moduleValue.type === 'flow') {
|
||
const subflow = await loadSubflow?.(moduleValue.path, workspace)
|
||
const previewValue = subflow?.previewValue
|
||
return {
|
||
module: targetModule,
|
||
runnableKind: 'flow',
|
||
schema: subflow?.schema,
|
||
startMessage: previewValue
|
||
? `Starting test run of draft flow step "${stepId}"...`
|
||
: `Starting test run of flow step "${stepId}"...`,
|
||
startJob: (args) =>
|
||
previewValue
|
||
? JobService.runFlowPreview({
|
||
workspace,
|
||
requestBody: { path: moduleValue.path, value: previewValue, args }
|
||
})
|
||
: JobService.runFlowByPath({
|
||
workspace,
|
||
path: moduleValue.path,
|
||
requestBody: args,
|
||
// As the flow editor's own step test does: these are the subflow's main input
|
||
// schema's arguments, and a preprocessor would take them for a trigger event
|
||
// and hand the flow its own output instead. A parent flow runs a subflow step
|
||
// the same way (apply_preprocessor: false).
|
||
skipPreprocessor: true
|
||
})
|
||
}
|
||
}
|
||
|
||
if (moduleValue.type === 'aiagent') {
|
||
return {
|
||
module: targetModule,
|
||
// The job started is a flow preview: an agent step has none of its own, and the tool
|
||
// calls it makes are that flow's steps.
|
||
runnableKind: 'flow',
|
||
startMessage: `Starting test run of agent step "${stepId}"...`,
|
||
startJob: async (args, formKeys) => {
|
||
// The step alone, stripped of the settings around it: a mock would answer for it
|
||
// and a suspend would hold the run for an approval nobody is watching for.
|
||
const value: FlowValue = {
|
||
modules: [
|
||
{
|
||
id: targetModule.id,
|
||
value: {
|
||
...moduleValue,
|
||
input_transforms: agentTestInputTransforms(
|
||
moduleValue.input_transforms,
|
||
args,
|
||
formKeys
|
||
)
|
||
}
|
||
}
|
||
]
|
||
}
|
||
return JobService.runFlowPreview({
|
||
workspace,
|
||
requestBody: {
|
||
// After the inputs are wired, as the flow editor's step test does: a linked agent's
|
||
// draft brings its own brain and tools, and the step's run inputs stay on top.
|
||
value: withAgentDrafts ? await withAgentDrafts(value) : value,
|
||
args,
|
||
path: flowPath
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
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, aiagent`
|
||
)
|
||
}
|
||
|
||
export async function executeFlowStepTestRun({
|
||
args,
|
||
background,
|
||
detachAfterMs,
|
||
...config
|
||
}: FlowStepTestRunConfig): Promise<string> {
|
||
const resolved = await resolveFlowStepRun(config)
|
||
return executeTestRun({
|
||
jobStarter: () => resolved.startJob(normalizeFlowStepArgs(args)),
|
||
workspace: config.workspace,
|
||
toolCallbacks: config.toolCallbacks,
|
||
toolId: config.toolId,
|
||
startMessage: resolved.startMessage,
|
||
contextName: resolved.runnableKind,
|
||
label: `step ${config.stepId}`,
|
||
background,
|
||
detachAfterMs
|
||
})
|
||
}
|
||
|
||
function formatLogs(logs: string | undefined): undefined | string {
|
||
if (logs && logs.trim()) {
|
||
if (logs.length <= MAX_LOG_LENGTH) {
|
||
return logs
|
||
} else {
|
||
return logs.slice(-MAX_LOG_LENGTH)
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function formatResult(result: unknown): string {
|
||
if (typeof result === 'string') {
|
||
return result
|
||
}
|
||
return JSON.stringify(result, null, 2)
|
||
}
|
||
|
||
function formatResultSummary(result: unknown, logs: string | undefined, success: boolean): string {
|
||
let resultSummary = ''
|
||
resultSummary += `Result (${success ? 'SUCCESS' : 'FAILED'})\n\n`
|
||
resultSummary += formatResult(result).slice(0, MAX_RESULT_LENGTH)
|
||
resultSummary += '\n\nLogs:\n\n'
|
||
resultSummary += formatLogs(logs) ?? 'No logs available'
|
||
return resultSummary
|
||
}
|
||
|
||
// ============= Script/Flow Lint Types =============
|
||
|
||
/** Result of linting a script */
|
||
export interface ScriptLintResult {
|
||
errorCount: number
|
||
warningCount: number
|
||
errors: meditor.IMarker[]
|
||
warnings: meditor.IMarker[]
|
||
}
|
||
|
||
/** Format script lint result for display */
|
||
export function formatScriptLintResult(lintResult: ScriptLintResult): string {
|
||
let response = ''
|
||
const hasIssues = lintResult.errorCount > 0 || lintResult.warningCount > 0
|
||
|
||
if (hasIssues) {
|
||
if (lintResult.errorCount > 0) {
|
||
response += `❌ **${lintResult.errorCount} error(s)** found that must be fixed:\n`
|
||
for (const error of lintResult.errors) {
|
||
response += `- Line ${error.startLineNumber}: ${error.message}\n`
|
||
}
|
||
}
|
||
|
||
if (lintResult.warningCount > 0) {
|
||
response += `\n⚠️ **${lintResult.warningCount} warning(s)** found:\n`
|
||
for (const warning of lintResult.warnings) {
|
||
response += `- Line ${warning.startLineNumber}: ${warning.message}\n`
|
||
}
|
||
}
|
||
} else {
|
||
response = '✅ No lint issues found.'
|
||
}
|
||
|
||
return response
|
||
}
|
||
|
||
export class WorkspaceRunnablesSearch {
|
||
private uf: uFuzzy
|
||
private scriptsWorkspace: string | undefined = undefined
|
||
private flowsWorkspace: string | undefined = undefined
|
||
private scripts: Script[] | undefined = undefined
|
||
private flows: Flow[] | undefined = undefined
|
||
private scriptCache: Map<string, Awaited<ReturnType<typeof ScriptService.getScriptByPath>>> =
|
||
new Map()
|
||
private flowCache: Map<string, Awaited<ReturnType<typeof FlowService.getFlowByPath>>> = new Map()
|
||
|
||
constructor() {
|
||
this.uf = new uFuzzy()
|
||
}
|
||
|
||
private async initScripts(workspace: string) {
|
||
if (this.scripts === undefined || this.scriptsWorkspace !== workspace) {
|
||
this.scripts = await ScriptService.listScripts({ workspace })
|
||
this.scriptsWorkspace = workspace
|
||
}
|
||
}
|
||
|
||
private async initFlows(workspace: string) {
|
||
if (this.flows === undefined || this.flowsWorkspace !== workspace) {
|
||
this.flows = await FlowService.listFlows({ workspace })
|
||
this.flowsWorkspace = workspace
|
||
}
|
||
}
|
||
|
||
async searchScripts(query: string, workspace: string) {
|
||
await this.initScripts(workspace)
|
||
const scripts = this.scripts
|
||
if (!scripts) return []
|
||
|
||
const trimmed = query.trim()
|
||
if (!trimmed) {
|
||
return scripts.map((s) => ({
|
||
type: 'script' as const,
|
||
path: s.path,
|
||
summary: s.summary
|
||
}))
|
||
}
|
||
|
||
const haystack = scripts.map((s) =>
|
||
emptyString(s.summary) ? s.path : s.summary + ' (' + s.path + ')'
|
||
)
|
||
const [idxs, , order] = this.uf.search(haystack, trimmed)
|
||
if (!idxs || !order) return []
|
||
return order.map((orderIdx) => {
|
||
const haystackIdx = idxs[orderIdx]
|
||
return {
|
||
type: 'script' as const,
|
||
path: scripts[haystackIdx].path,
|
||
summary: scripts[haystackIdx].summary
|
||
}
|
||
})
|
||
}
|
||
|
||
async searchFlows(query: string, workspace: string) {
|
||
await this.initFlows(workspace)
|
||
const flows = this.flows
|
||
if (!flows) return []
|
||
|
||
const trimmed = query.trim()
|
||
if (!trimmed) {
|
||
return flows.map((f) => ({
|
||
type: 'flow' as const,
|
||
path: f.path,
|
||
summary: f.summary
|
||
}))
|
||
}
|
||
|
||
const haystack = flows.map((f) =>
|
||
emptyString(f.summary) ? f.path : f.summary + ' (' + f.path + ')'
|
||
)
|
||
const [idxs, , order] = this.uf.search(haystack, trimmed)
|
||
if (!idxs || !order) return []
|
||
return order.map((orderIdx) => {
|
||
const haystackIdx = idxs[orderIdx]
|
||
return {
|
||
type: 'flow' as const,
|
||
path: flows[haystackIdx].path,
|
||
summary: flows[haystackIdx].summary
|
||
}
|
||
})
|
||
}
|
||
|
||
async search(query: string, workspace: string, type: 'all' | 'scripts' | 'flows' = 'all') {
|
||
const results: { type: 'script' | 'flow'; path: string; summary: string }[] = []
|
||
|
||
if (type === 'all' || type === 'scripts') {
|
||
results.push(...(await this.searchScripts(query, workspace)))
|
||
}
|
||
if (type === 'all' || type === 'flows') {
|
||
results.push(...(await this.searchFlows(query, workspace)))
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
async getScript(path: string, workspace: string) {
|
||
const key = `${workspace}:${path}`
|
||
let cached = this.scriptCache.get(key)
|
||
if (!cached) {
|
||
cached = await ScriptService.getScriptByPath({ workspace, path })
|
||
this.scriptCache.set(key, cached)
|
||
}
|
||
return cached
|
||
}
|
||
|
||
async getFlow(path: string, workspace: string) {
|
||
const key = `${workspace}:${path}`
|
||
let cached = this.flowCache.get(key)
|
||
if (!cached) {
|
||
cached = await FlowService.getFlowByPath({ workspace, path })
|
||
this.flowCache.set(key, cached)
|
||
}
|
||
return cached
|
||
}
|
||
}
|
||
|
||
const searchWorkspaceSchema = z.object({
|
||
query: z
|
||
.string()
|
||
.describe('Comma separated list of keywords to search for (e.g. "stripe, send email, ETL")'),
|
||
type: z
|
||
.enum(['all', 'scripts', 'flows'])
|
||
.describe(
|
||
'Filter by type: "all" for both scripts and flows, "scripts" for scripts only, "flows" for flows only.'
|
||
)
|
||
})
|
||
|
||
const searchWorkspaceToolDef = createToolDef(
|
||
searchWorkspaceSchema,
|
||
'search_workspace',
|
||
'Search for scripts and flows in the workspace. Use this when a user asks about existing building blocks, wants to find a script/flow, or asks "what do I have for X". ALWAYS search really broadly.'
|
||
)
|
||
|
||
export const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
|
||
|
||
export const createSearchWorkspaceTool = () => ({
|
||
def: searchWorkspaceToolDef,
|
||
planModeSafe: true,
|
||
fn: async ({
|
||
args,
|
||
workspace,
|
||
toolId,
|
||
toolCallbacks
|
||
}: {
|
||
args: any
|
||
workspace: string
|
||
toolId: string
|
||
toolCallbacks: ToolCallbacks
|
||
}) => {
|
||
const parsedArgs = searchWorkspaceSchema.parse(args)
|
||
const type = parsedArgs.type
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Searching workspace...`
|
||
})
|
||
|
||
const results: { type: 'script' | 'flow'; path: string; summary: string }[] = []
|
||
const keywords = parsedArgs.query.split(',').map((keyword) => keyword.trim())
|
||
const seenPaths = new Set<string>()
|
||
for (const keyword of keywords) {
|
||
const keywordResults = await workspaceRunnablesSearch.search(keyword, workspace, type)
|
||
for (const result of keywordResults) {
|
||
if (!seenPaths.has(result.path)) {
|
||
results.push(result)
|
||
seenPaths.add(result.path)
|
||
}
|
||
}
|
||
}
|
||
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Found ${results.length} result(s)`
|
||
})
|
||
return JSON.stringify(results, null, 2)
|
||
}
|
||
})
|
||
|
||
const getRunnableDetailsSchema = z.object({
|
||
path: z.string().describe('The path of the script or flow (e.g. "f/marketing/send_email")'),
|
||
type: z.enum(['script', 'flow']).describe('Whether this is a script or a flow')
|
||
})
|
||
|
||
const getRunnableDetailsToolDef = createToolDef(
|
||
getRunnableDetailsSchema,
|
||
'get_runnable_details',
|
||
'Get details (summary, description, inputs schema, content) of a specific script or flow by path'
|
||
)
|
||
|
||
export const createGetRunnableDetailsTool = () => ({
|
||
def: getRunnableDetailsToolDef,
|
||
planModeSafe: true,
|
||
fn: async ({
|
||
args,
|
||
workspace,
|
||
toolId,
|
||
toolCallbacks
|
||
}: {
|
||
args: any
|
||
workspace: string
|
||
toolId: string
|
||
toolCallbacks: ToolCallbacks
|
||
}) => {
|
||
const parsedArgs = getRunnableDetailsSchema.parse(args)
|
||
const { path, type } = parsedArgs
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Getting ${type} details for "${path}"...`
|
||
})
|
||
|
||
try {
|
||
if (type === 'script') {
|
||
const script = await workspaceRunnablesSearch.getScript(path, workspace)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Retrieved script details for "${path}"`
|
||
})
|
||
const content = script.content ?? ''
|
||
const truncatedContent =
|
||
content.length > MAX_RUNNABLE_CONTENT_LENGTH
|
||
? content.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
|
||
: content
|
||
return JSON.stringify(
|
||
{
|
||
path: script.path,
|
||
summary: script.summary,
|
||
description: script.description,
|
||
language: script.language,
|
||
schema: script.schema,
|
||
content: truncatedContent
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
} else {
|
||
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Retrieved flow details for "${path}"`
|
||
})
|
||
const flowValue = JSON.stringify(flow.value, null, 2)
|
||
const truncatedValue =
|
||
flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH
|
||
? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
|
||
: flowValue
|
||
return JSON.stringify(
|
||
{
|
||
path: flow.path,
|
||
summary: flow.summary,
|
||
description: flow.description,
|
||
schema: flow.schema,
|
||
value: truncatedValue
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||
toolCallbacks.setToolStatus(toolId, {
|
||
content: `Error getting ${type} details`,
|
||
error: errorMessage
|
||
})
|
||
return `Error getting ${type} details for "${path}": ${errorMessage}`
|
||
}
|
||
}
|
||
})
|