mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
1571 lines
43 KiB
TypeScript
1571 lines
43 KiB
TypeScript
import type {
|
|
ChatCompletionFunctionTool,
|
|
ChatCompletionMessageFunctionToolCall,
|
|
ChatCompletionMessageParam
|
|
} from 'openai/resources/chat/completions.mjs'
|
|
|
|
/**
|
|
* 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 { 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 type { FunctionParameters } from 'openai/resources/shared.mjs'
|
|
import { z } from 'zod'
|
|
import {
|
|
ScriptService,
|
|
FlowService,
|
|
JobService,
|
|
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 { scriptLangToEditorLang } from '$lib/scripts'
|
|
import { getCurrentModel } from '$lib/aiStore'
|
|
import { type editor as meditor } from 'monaco-editor'
|
|
|
|
// 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`
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export type CreatedResourceTriggerKind =
|
|
| 'http'
|
|
| 'websocket'
|
|
| 'kafka'
|
|
| 'nats'
|
|
| 'postgres'
|
|
| 'mqtt'
|
|
| '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
|
|
}
|
|
|
|
export type ToolDisplayAction = CreatedResourceAction
|
|
|
|
export type UserQuestionDisplay = {
|
|
question: string
|
|
choices: string[]
|
|
selectedChoice?: string
|
|
canceled?: boolean
|
|
}
|
|
|
|
export type ToolDisplayMessage = {
|
|
role: 'tool'
|
|
tool_call_id: string
|
|
content: string
|
|
parameters?: any
|
|
result?: any
|
|
logs?: string
|
|
isLoading?: boolean
|
|
error?: string
|
|
needsConfirmation?: boolean
|
|
showDetails?: boolean
|
|
autoCollapseDetails?: boolean
|
|
isStreamingArguments?: boolean
|
|
toolName?: string
|
|
showFade?: boolean
|
|
actions?: ToolDisplayAction[]
|
|
userQuestion?: UserQuestionDisplay
|
|
}
|
|
|
|
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
|
role: 'assistant'
|
|
}
|
|
|
|
export type DisplayMessage = UserDisplayMessage | ToolDisplayMessage | AssistantDisplayMessage
|
|
|
|
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.`
|
|
)
|
|
}
|
|
return tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
|
|
}
|
|
|
|
type MaybePromise<T> = T | Promise<T>
|
|
|
|
export async function processToolCall<T>({
|
|
tools,
|
|
toolCall,
|
|
helpers,
|
|
toolCallbacks,
|
|
workspace
|
|
}: {
|
|
tools: Tool<T>[]
|
|
toolCall: ChatCompletionMessageFunctionToolCall
|
|
helpers: T
|
|
toolCallbacks: ToolCallbacks
|
|
workspace?: string
|
|
}): Promise<ChatCompletionMessageParam> {
|
|
try {
|
|
const args = JSON.parse(toolCall.function.arguments || '{}')
|
|
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
|
|
const workspaceId = workspace ?? get(workspaceStore) ?? ''
|
|
|
|
const validationError = await tool?.validateBeforeConfirmation?.({
|
|
args,
|
|
workspace: workspaceId,
|
|
helpers
|
|
})
|
|
if (validationError) {
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
content: validationError,
|
|
parameters: args,
|
|
isLoading: false,
|
|
isStreamingArguments: false,
|
|
error: validationError,
|
|
needsConfirmation: false,
|
|
showDetails: tool?.showDetails,
|
|
autoCollapseDetails: tool?.autoCollapseDetails
|
|
})
|
|
return {
|
|
role: 'tool' as const,
|
|
tool_call_id: toolCall.id,
|
|
content: validationError
|
|
}
|
|
}
|
|
|
|
// Check if tool requires confirmation
|
|
const requiresConfirmation = tool?.requiresConfirmation === true
|
|
const autoAcceptConfirmation =
|
|
requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true
|
|
const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
|
|
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
...(requiresConfirmation
|
|
? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' }
|
|
: {}),
|
|
parameters: args,
|
|
isLoading: true,
|
|
needsConfirmation: needsConfirmation,
|
|
showDetails: tool?.showDetails,
|
|
autoCollapseDetails: tool?.autoCollapseDetails
|
|
})
|
|
|
|
// If confirmation is needed and we have the callback, wait for it
|
|
if (needsConfirmation && toolCallbacks.requestConfirmation) {
|
|
const confirmed = await toolCallbacks.requestConfirmation(toolCall.id)
|
|
|
|
if (!confirmed) {
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
content: 'Cancelled by user',
|
|
isLoading: false,
|
|
isStreamingArguments: false,
|
|
error: 'Tool execution was cancelled by user',
|
|
needsConfirmation: false
|
|
})
|
|
return {
|
|
role: 'tool' as const,
|
|
tool_call_id: toolCall.id,
|
|
content: 'Tool execution was cancelled by user'
|
|
}
|
|
}
|
|
|
|
// Update status to executing after confirmation
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
isLoading: true,
|
|
needsConfirmation: false
|
|
})
|
|
}
|
|
|
|
let result = ''
|
|
try {
|
|
result = await callTool({
|
|
tools,
|
|
functionName: toolCall.function.name,
|
|
args,
|
|
workspace: workspaceId,
|
|
helpers,
|
|
toolCallbacks,
|
|
toolId: toolCall.id
|
|
})
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
isLoading: false,
|
|
isStreamingArguments: false
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
toolCallbacks.setToolStatus(toolCall.id, {
|
|
isLoading: false,
|
|
isStreamingArguments: false,
|
|
error: 'An error occurred while calling the tool'
|
|
})
|
|
const errorMessage =
|
|
typeof err === 'object' && 'message' in err
|
|
? err.message
|
|
: typeof err === 'string'
|
|
? err
|
|
: 'An error occurred while calling the tool'
|
|
result = `Error while calling tool: ${errorMessage}`
|
|
}
|
|
const toAdd = {
|
|
role: 'tool' as const,
|
|
tool_call_id: toolCall.id,
|
|
content: result
|
|
}
|
|
return toAdd
|
|
} catch (err) {
|
|
console.error(err)
|
|
return {
|
|
role: 'tool' as const,
|
|
tool_call_id: toolCall.id,
|
|
content: 'Error while calling tool'
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
validateBeforeConfirmation?: (p: {
|
|
args: any
|
|
workspace: string
|
|
helpers: T
|
|
}) => MaybePromise<string | undefined>
|
|
setSchema?: (helpers: any) => Promise<void>
|
|
requiresConfirmation?: boolean
|
|
confirmationMessage?: string
|
|
showDetails?: boolean
|
|
autoCollapseDetails?: boolean
|
|
streamArguments?: boolean
|
|
showFade?: boolean
|
|
}
|
|
|
|
export interface ToolCallbacks {
|
|
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
|
|
removeToolStatus: (id: string) => void
|
|
requestConfirmation?: (toolId: string) => Promise<boolean>
|
|
shouldAutoAcceptToolConfirmations?: () => boolean
|
|
requestUserQuestion?: (
|
|
toolId: string,
|
|
question: UserQuestionDisplay
|
|
) => Promise<string | undefined>
|
|
}
|
|
|
|
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'
|
|
)
|
|
|
|
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
|
|
def: searchHubScriptsToolDef,
|
|
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'
|
|
})
|
|
toolCallbacks.setToolStatus(toolId, {
|
|
content: 'Found ' + scripts.length + ' scripts in the hub related to "' + args.query + '"'
|
|
})
|
|
// if withContent, fetch scripts with their content, limit to 3 results
|
|
const results = await Promise.all(
|
|
scripts.slice(0, withContent ? 3 : undefined).map(async (s) => {
|
|
let content = ''
|
|
if (withContent) {
|
|
content = await ScriptService.getHubScriptContentByPath({
|
|
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
|
|
})
|
|
}
|
|
return {
|
|
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
|
|
summary: s.summary,
|
|
...(withContent ? { content } : {})
|
|
}
|
|
})
|
|
)
|
|
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(', ')}`)
|
|
}
|
|
|
|
toolDef.function.parameters = { ...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') {
|
|
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
|
|
const MAX_LOG_LENGTH = 4000
|
|
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
|
|
|
export interface TestRunConfig {
|
|
jobStarter: () => Promise<string>
|
|
workspace: string
|
|
toolCallbacks: ToolCallbacks
|
|
toolId: string
|
|
startMessage?: string
|
|
contextName: 'script' | 'flow'
|
|
}
|
|
|
|
// Common job polling function
|
|
export async function pollJobCompletion(
|
|
jobId: string,
|
|
workspace: string,
|
|
toolId: string,
|
|
toolCallbacks: ToolCallbacks
|
|
): Promise<CompletedJob> {
|
|
let attempts = 0
|
|
const maxAttempts = 60
|
|
let job: CompletedJob | null = null
|
|
|
|
while (attempts < maxAttempts) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
attempts++
|
|
|
|
try {
|
|
const fetchedJob = await JobService.getJob({
|
|
workspace: workspace,
|
|
id: jobId,
|
|
noLogs: false,
|
|
noCode: true
|
|
})
|
|
|
|
if (fetchedJob.type === 'CompletedJob') {
|
|
job = fetchedJob
|
|
break
|
|
}
|
|
} catch (error) {
|
|
if (attempts >= maxAttempts) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!job) {
|
|
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
|
|
}
|
|
|
|
// Main execution function for test runs
|
|
export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
|
try {
|
|
config.toolCallbacks.setToolStatus(config.toolId, {
|
|
content: config.startMessage || `Starting ${config.contextName} test...`
|
|
})
|
|
|
|
const jobId = await config.jobStarter()
|
|
|
|
const contextName = config.contextName.charAt(0).toUpperCase() + config.contextName.slice(1)
|
|
|
|
config.toolCallbacks.setToolStatus(config.toolId, {
|
|
content: `${contextName} test started, waiting for completion...`
|
|
})
|
|
|
|
const job = await pollJobCompletion(
|
|
jobId,
|
|
config.workspace,
|
|
config.toolId,
|
|
config.toolCallbacks
|
|
)
|
|
|
|
config.toolCallbacks.setToolStatus(config.toolId, {
|
|
content: `${contextName} test ${job.success ? 'completed successfully' : 'failed'}`,
|
|
result: formatResult(job.result),
|
|
logs: formatLogs(job.logs),
|
|
...(job.success ? {} : { error: getErrorMessage(job.result) })
|
|
})
|
|
|
|
return formatResultSummary(job.result, job.logs, job.success)
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
|
config.toolCallbacks.setToolStatus(config.toolId, {
|
|
content: `Test execution failed`,
|
|
error: errorMessage
|
|
})
|
|
throw new Error(`Failed to execute test run: ${errorMessage}`)
|
|
}
|
|
}
|
|
|
|
type FlowStepScriptLoader = (
|
|
moduleValue: { path: string; hash?: string },
|
|
workspace: string
|
|
) => Promise<{ content: string; language: ScriptLang }>
|
|
|
|
type FlowStepPreviewLoader = (path: string, workspace: string) => Promise<FlowValue | undefined>
|
|
|
|
export type FlowStepTestRunConfig = {
|
|
flowValue: FlowValue
|
|
stepId: string
|
|
args?: Record<string, any> | null
|
|
workspace: string
|
|
toolCallbacks: ToolCallbacks
|
|
toolId: string
|
|
loadScript?: FlowStepScriptLoader
|
|
loadFlowPreviewValue?: FlowStepPreviewLoader
|
|
}
|
|
|
|
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 }> {
|
|
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 }
|
|
}
|
|
|
|
export async function executeFlowStepTestRun({
|
|
flowValue,
|
|
stepId,
|
|
args,
|
|
workspace,
|
|
toolCallbacks,
|
|
toolId,
|
|
loadScript = loadDeployedScriptForFlowStep,
|
|
loadFlowPreviewValue
|
|
}: FlowStepTestRunConfig): Promise<string> {
|
|
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 stepArgs = normalizeFlowStepArgs(args)
|
|
|
|
if (moduleValue.type === 'rawscript') {
|
|
return executeTestRun({
|
|
jobStarter: () =>
|
|
JobService.runScriptPreview({
|
|
workspace,
|
|
requestBody: {
|
|
content: moduleValue.content ?? '',
|
|
language: moduleValue.language,
|
|
args: flowStepArgsForModule(targetModule.id, stepArgs)
|
|
}
|
|
}),
|
|
workspace,
|
|
toolCallbacks,
|
|
toolId,
|
|
startMessage: `Starting test run of step "${stepId}"...`,
|
|
contextName: 'script'
|
|
})
|
|
}
|
|
|
|
if (moduleValue.type === 'script') {
|
|
const script = await loadScript(moduleValue, workspace)
|
|
return executeTestRun({
|
|
jobStarter: () =>
|
|
JobService.runScriptPreview({
|
|
workspace,
|
|
requestBody: {
|
|
path: moduleValue.path,
|
|
content: script.content,
|
|
language: script.language,
|
|
args: flowStepArgsForModule(targetModule.id, stepArgs)
|
|
}
|
|
}),
|
|
workspace,
|
|
toolCallbacks,
|
|
toolId,
|
|
startMessage: `Starting test run of script step "${stepId}"...`,
|
|
contextName: 'script'
|
|
})
|
|
}
|
|
|
|
if (moduleValue.type === 'flow') {
|
|
const previewValue = await loadFlowPreviewValue?.(moduleValue.path, workspace)
|
|
if (previewValue) {
|
|
return executeTestRun({
|
|
jobStarter: () =>
|
|
JobService.runFlowPreview({
|
|
workspace,
|
|
requestBody: {
|
|
path: moduleValue.path,
|
|
value: previewValue,
|
|
args: stepArgs
|
|
}
|
|
}),
|
|
workspace,
|
|
toolCallbacks,
|
|
toolId,
|
|
startMessage: `Starting test run of draft flow step "${stepId}"...`,
|
|
contextName: 'flow'
|
|
})
|
|
}
|
|
|
|
return executeTestRun({
|
|
jobStarter: () =>
|
|
JobService.runFlowByPath({
|
|
workspace,
|
|
path: moduleValue.path,
|
|
requestBody: stepArgs
|
|
}),
|
|
workspace,
|
|
toolCallbacks,
|
|
toolId,
|
|
startMessage: `Starting test run of flow step "${stepId}"...`,
|
|
contextName: 'flow'
|
|
})
|
|
}
|
|
|
|
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`
|
|
)
|
|
}
|
|
|
|
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,
|
|
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,
|
|
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}`
|
|
}
|
|
}
|
|
})
|