feat: add search_flows and get_flow_details tools to flow mode AI chat
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
157
PLAN.md
Normal file
157
PLAN.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Plan 4: Flow Mode — Add Flow Search Tool with Auto-Search Behavior
|
||||
|
||||
## Problem
|
||||
|
||||
Flow mode can search for **scripts** (via `search_scripts`) but cannot search for **flows**. When building a new flow, users can't discover existing flows they could reference as sub-flows. The AI should proactively search for existing flows after any user request, just like it already searches for scripts.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a `search_flows` tool to flow mode and instruct the AI to proactively search for existing flows and scripts after any request, enabling discovery and reuse of flow building blocks.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
- **File**: `frontend/src/lib/components/copilot/chat/flow/core.ts`
|
||||
- **Existing tools**: `search_scripts`, `search_hub_scripts`, `resource_type`, `get_instructions_for_code_generation`, `get_db_schema`, `set_flow_json`, `inspect_inline_script`, `set_module_code`, `test_run_flow`, `test_run_step`, `get_lint_errors`
|
||||
- **`search_scripts`** uses `WorkspaceScriptsSearch` (uFuzzy over `ScriptService.listScripts()`)
|
||||
- **No flow search** — the AI cannot find existing flows to reuse as sub-flow modules
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add `search_flows` tool
|
||||
|
||||
```typescript
|
||||
const searchFlowsSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe('The query to search for, e.g. "process invoices", "send notification", etc.')
|
||||
})
|
||||
|
||||
const searchFlowsToolDef = createToolDef(
|
||||
searchFlowsSchema,
|
||||
'search_flows',
|
||||
'Search for flows in the workspace. Returns array of {path, summary} objects. Use this to find existing flows that can be referenced as sub-flow modules.'
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Add `WorkspaceFlowsSearch` class (or reuse shared class)
|
||||
|
||||
Either reuse the shared `WorkspaceRunnablesSearch` from Plan 1, or add a dedicated flow search class:
|
||||
|
||||
```typescript
|
||||
class WorkspaceFlowsSearch {
|
||||
private uf: uFuzzy
|
||||
private workspace: string | undefined = undefined
|
||||
private flows: Flow[] | undefined = undefined
|
||||
|
||||
constructor() { this.uf = new uFuzzy() }
|
||||
|
||||
private async init(workspace: string) {
|
||||
this.flows = await FlowService.listFlows({ workspace })
|
||||
this.workspace = workspace
|
||||
}
|
||||
|
||||
async search(query: string, workspace: string) {
|
||||
if (this.flows === undefined || this.workspace !== workspace) {
|
||||
await this.init(workspace)
|
||||
}
|
||||
const flows = this.flows
|
||||
if (!flows) throw new Error('Failed to load flows')
|
||||
|
||||
const results = this.uf.search(
|
||||
flows.map((f) => (emptyString(f.summary) ? f.path : f.summary + ' (' + f.path + ')')),
|
||||
query.trim()
|
||||
)
|
||||
return results[2]?.map((id) => ({
|
||||
path: flows[id].path,
|
||||
summary: flows[id].summary
|
||||
})) ?? []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add `get_flow_details` tool (to inspect found flows)
|
||||
|
||||
```typescript
|
||||
const getFlowDetailsSchema = z.object({
|
||||
path: z.string().describe('The path of the flow to inspect')
|
||||
})
|
||||
|
||||
const getFlowDetailsToolDef = createToolDef(
|
||||
getFlowDetailsSchema,
|
||||
'get_flow_details',
|
||||
'Get details of a workspace flow including its modules structure, input schema, and module summaries. Use after search_flows to understand a flow before referencing it.'
|
||||
)
|
||||
```
|
||||
|
||||
Implementation calls `FlowService.getFlowByPath()` and returns:
|
||||
- Flow summary, description
|
||||
- Input schema
|
||||
- Module structure (ids, summaries, types, paths for script references)
|
||||
|
||||
### 4. Update system prompt — Proactive Search Instruction
|
||||
|
||||
This is the key difference from script mode. The system prompt should instruct the AI to **always search for existing flows** when creating or modifying a flow:
|
||||
|
||||
Update the "Creating Flows" section in `prepareFlowSystemMessage()`:
|
||||
|
||||
```
|
||||
### Creating Flows
|
||||
|
||||
1. **Search for existing flows and scripts first** (unless user explicitly asks to write from scratch):
|
||||
- First: `search_flows` to find existing flows that could be reused as sub-flow modules
|
||||
- Then: `search_scripts` to find workspace scripts
|
||||
- Then: `search_hub_scripts` (only consider highly relevant results)
|
||||
- Only create raw scripts if no suitable script or flow is found
|
||||
|
||||
2. **When referencing an existing flow as a sub-module:**
|
||||
- Use `get_flow_details` to understand the flow's input schema
|
||||
- Use `type: "flow"` with `path` in the module value
|
||||
- Define `input_transforms` matching the referenced flow's input schema
|
||||
```
|
||||
|
||||
### 5. Register tools
|
||||
|
||||
Add to the `flowTools` array:
|
||||
|
||||
```typescript
|
||||
export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
// ... existing tools
|
||||
{
|
||||
def: searchFlowsToolDef,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Searching for workspace flows related to "' + args.query + '"...'
|
||||
})
|
||||
const parsedArgs = searchFlowsSchema.parse(args)
|
||||
const flowResults = await workspaceFlowsSearch.search(parsedArgs.query, workspace)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Found ' + flowResults.length + ' flows in the workspace related to "' + args.query + '"'
|
||||
})
|
||||
return JSON.stringify(flowResults)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: getFlowDetailsToolDef,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
// ... implementation
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/lib/components/copilot/chat/flow/core.ts` | Add `search_flows` and `get_flow_details` tools, update system prompt, add search class, register tools |
|
||||
| `frontend/src/lib/components/copilot/chat/shared.ts` | Ensure shared flow search utility if extracted |
|
||||
|
||||
## Testing
|
||||
|
||||
1. Open a flow in flow mode
|
||||
2. Ask "Create a flow that sends a notification after processing an invoice"
|
||||
3. AI should proactively search for existing flows related to "notification" and "invoice"
|
||||
4. AI should search for existing scripts too
|
||||
5. If matches are found, AI should propose reusing them
|
||||
6. Ask "I have a flow for Stripe webhook handling, can I add it as a sub-flow here?"
|
||||
7. AI should find the flow, inspect its inputs, and add it as a `type: "flow"` module
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ScriptService,
|
||||
FlowService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type InputTransform,
|
||||
type RawScript,
|
||||
@@ -208,10 +210,7 @@ function getExpectedFormat(schema: z.ZodType): string | null {
|
||||
let current = schema
|
||||
|
||||
// Unwrap optional/nullable to get inner type
|
||||
while (
|
||||
(current as any)._def.type === 'optional' ||
|
||||
(current as any)._def.type === 'nullable'
|
||||
) {
|
||||
while ((current as any)._def.type === 'optional' || (current as any)._def.type === 'nullable') {
|
||||
current = (current as any)._def.innerType
|
||||
if (!current || !(current as any)._def) break
|
||||
}
|
||||
@@ -378,6 +377,68 @@ class WorkspaceScriptsSearch {
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceFlowsSearch {
|
||||
private uf: uFuzzy
|
||||
private workspace: string | undefined = undefined
|
||||
private flows: Flow[] | undefined = undefined
|
||||
|
||||
constructor() {
|
||||
this.uf = new uFuzzy()
|
||||
}
|
||||
|
||||
private async init(workspace: string) {
|
||||
this.flows = await FlowService.listFlows({ workspace })
|
||||
this.workspace = workspace
|
||||
}
|
||||
|
||||
async search(query: string, workspace: string) {
|
||||
if (this.flows === undefined || this.workspace !== workspace) {
|
||||
await this.init(workspace)
|
||||
}
|
||||
|
||||
const flows = this.flows
|
||||
|
||||
if (!flows) {
|
||||
throw new Error('Failed to load flows')
|
||||
}
|
||||
|
||||
const results = this.uf.search(
|
||||
flows.map((f) => (emptyString(f.summary) ? f.path : f.summary + ' (' + f.path + ')')),
|
||||
query.trim()
|
||||
)
|
||||
return (
|
||||
results[2]?.map((id) => ({
|
||||
path: flows[id].path,
|
||||
summary: flows[id].summary
|
||||
})) ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const searchFlowsSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe('The query to search for, e.g. "process invoices", "send notification", etc.')
|
||||
})
|
||||
|
||||
const searchFlowsToolDef = createToolDef(
|
||||
searchFlowsSchema,
|
||||
'search_flows',
|
||||
'Search for flows in the workspace. Returns array of {path, summary} objects. Use this to find existing flows that can be referenced as sub-flow modules.'
|
||||
)
|
||||
|
||||
const getFlowDetailsSchema = z.object({
|
||||
path: z.string().describe('The path of the flow to inspect')
|
||||
})
|
||||
|
||||
const getFlowDetailsToolDef = createToolDef(
|
||||
getFlowDetailsSchema,
|
||||
'get_flow_details',
|
||||
'Get details of a workspace flow including its modules structure, input schema, and module summaries. Use after search_flows to understand a flow before referencing it.'
|
||||
)
|
||||
|
||||
const workspaceFlowsSearch = new WorkspaceFlowsSearch()
|
||||
|
||||
// Will be overridden by setSchema
|
||||
const testRunFlowSchema = z.object({
|
||||
args: z
|
||||
@@ -464,6 +525,54 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
return JSON.stringify(scriptResults)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: searchFlowsToolDef,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Searching for workspace flows related to "' + args.query + '"...'
|
||||
})
|
||||
const parsedArgs = searchFlowsSchema.parse(args)
|
||||
const flowResults = await workspaceFlowsSearch.search(parsedArgs.query, workspace)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content:
|
||||
'Found ' + flowResults.length + ' flows in the workspace related to "' + args.query + '"'
|
||||
})
|
||||
return JSON.stringify(flowResults)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: getFlowDetailsToolDef,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsedArgs = getFlowDetailsSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Getting details for flow "' + parsedArgs.path + '"...'
|
||||
})
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace,
|
||||
path: parsedArgs.path
|
||||
})
|
||||
const modules = flow.value?.modules ?? []
|
||||
const moduleSummaries = modules.map((m: FlowModule) => ({
|
||||
id: m.id,
|
||||
summary: m.summary,
|
||||
type: m.value.type,
|
||||
...(m.value.type === 'script' ? { path: m.value.path } : {}),
|
||||
...(m.value.type === 'flow' ? { path: m.value.path } : {}),
|
||||
...(m.value.type === 'rawscript' ? { language: m.value.language } : {})
|
||||
}))
|
||||
const result = {
|
||||
path: flow.path,
|
||||
summary: flow.summary,
|
||||
description: flow.description,
|
||||
schema: flow.schema,
|
||||
modules: moduleSummaries
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Retrieved details for flow "' + parsedArgs.path + '"'
|
||||
})
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: resourceTypeToolDef,
|
||||
fn: async ({ args, toolId, workspace, toolCallbacks }) => {
|
||||
@@ -748,8 +857,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
const path = e.path
|
||||
// Try to find module id for better context
|
||||
const moduleIndex = typeof path[0] === 'number' ? path[0] : undefined
|
||||
const moduleId =
|
||||
moduleIndex !== undefined ? parsedModules[moduleIndex]?.id : undefined
|
||||
const moduleId = moduleIndex !== undefined ? parsedModules[moduleIndex]?.id : undefined
|
||||
const fieldPath = path.slice(1).join('.')
|
||||
|
||||
let message = e.message
|
||||
@@ -852,6 +960,8 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS
|
||||
- **View existing inline script code** → \`inspect_inline_script\`
|
||||
- **Change module code only** → \`set_module_code\`
|
||||
- **Get language-specific coding instructions** → \`get_instructions_for_code_generation\` (call BEFORE writing code)
|
||||
- **Find workspace flows** → \`search_flows\` (find existing flows to reuse as sub-flow modules)
|
||||
- **Get flow details** → \`get_flow_details\` (inspect a flow's input schema and modules before referencing it)
|
||||
- **Find workspace scripts** → \`search_scripts\`
|
||||
- **Find Windmill Hub scripts** → \`search_hub_scripts\`
|
||||
|
||||
@@ -1058,18 +1168,25 @@ Example: Before writing TypeScript/Bun code, call \`get_instructions_for_code_ge
|
||||
|
||||
### Creating Flows
|
||||
|
||||
1. **Search for existing scripts first** (unless user explicitly asks to write from scratch):
|
||||
- First: \`search_scripts\` to find workspace scripts
|
||||
1. **Search for existing flows and scripts first** (unless user explicitly asks to write from scratch):
|
||||
- First: \`search_flows\` to find existing flows that could be reused as sub-flow modules
|
||||
- Then: \`search_scripts\` to find workspace scripts
|
||||
- Then: \`search_hub_scripts\` (only consider highly relevant results)
|
||||
- Only create raw scripts if no suitable script is found
|
||||
- Only create raw scripts if no suitable flow or script is found
|
||||
|
||||
2. **Build the complete flow using \`set_flow_json\`:**
|
||||
2. **When referencing an existing flow as a sub-module:**
|
||||
- Use \`get_flow_details\` to understand the flow's input schema
|
||||
- Use \`type: "flow"\` with \`path\` in the module value
|
||||
- Define \`input_transforms\` matching the referenced flow's input schema
|
||||
|
||||
3. **Build the complete flow using \`set_flow_json\`:**
|
||||
- If using existing flow: use \`type: "flow"\` with \`path\`
|
||||
- If using existing script: use \`type: "script"\` with \`path\`
|
||||
- If creating rawscript: use \`type: "rawscript"\` with \`language\` and \`content\`
|
||||
- **First call \`get_instructions_for_code_generation\` to get the correct code format**
|
||||
- Always define \`input_transforms\` to connect parameters to flow inputs or previous step results
|
||||
|
||||
3. **After making code changes, ALWAYS use \`get_lint_errors\` to check for issues.** Fix any errors before proceeding with testing.
|
||||
4. **After making code changes, ALWAYS use \`get_lint_errors\` to check for issues.** Fix any errors before proceeding with testing.
|
||||
|
||||
### AI Agent Modules
|
||||
|
||||
|
||||
Reference in New Issue
Block a user