diff --git a/backend/windmill-worker/src/ai/types.rs b/backend/windmill-worker/src/ai/types.rs index 128314c598..f28331cdb8 100644 --- a/backend/windmill-worker/src/ai/types.rs +++ b/backend/windmill-worker/src/ai/types.rs @@ -2,6 +2,7 @@ use crate::ai::providers::openai::OpenAIToolCall; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::collections::HashMap; +use uuid::Uuid; use windmill_common::mcp_client::McpToolSource; use windmill_common::{ ai_providers::AIProvider, db::DB, error::Error, flow_status::AgentAction, flows::FlowModule, @@ -121,9 +122,12 @@ impl Default for OutputType { #[derive(Deserialize, Debug, Clone)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Memory { + Off, Auto { #[serde(default)] context_length: usize, + #[serde(default)] + memory_id: Option, }, Manual { messages: Vec, @@ -168,7 +172,16 @@ impl From for AIAgentArgs { // Backward compatibility: if messages_context_length is set, use auto mode let memory = raw.memory.or_else(|| { raw.messages_context_length - .map(|context_length| Memory::Auto { context_length }) + .map(|context_length| Memory::Auto { context_length, memory_id: None }) + }); + + // Backward compatibility: if context_length is 0, use off mode + let memory = memory.map(|memory| { + if let Memory::Auto { context_length: 0, .. } = memory { + Memory::Off + } else { + memory + } }); AIAgentArgs { diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 75979b3106..76160cfa6b 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -424,6 +424,19 @@ pub async fn run_agent( let is_text_output = output_type == &OutputType::Text; + // Flow-level memory_id (from chat mode) takes precedence over step-level memory_id + let memory_id = flow_context + .flow_status + .as_ref() + .and_then(|fs| fs.memory_id) + .or_else(|| { + // Extract memory_id from Memory::Auto if present + match &args.memory { + Some(Memory::Auto { memory_id, .. }) => *memory_id, + _ => None, + } + }); + // Load messages based on history mode if matches!(output_type, OutputType::Text) { match &args.memory { @@ -433,14 +446,10 @@ pub async fn run_agent( messages.extend(manual_messages.clone()); } } - Some(Memory::Auto { context_length }) if *context_length > 0 => { + Some(Memory::Auto { context_length, .. }) => { // Auto mode: load from memory if let Some(step_id) = job.flow_step_id.as_deref() { - if let Some(memory_id) = flow_context - .flow_status - .as_ref() - .and_then(|fs| fs.memory_id) - { + if let Some(memory_id) = memory_id { // Read messages from memory match read_from_memory(db, &job.workspace_id, memory_id, step_id).await { Ok(Some(loaded_messages)) => { @@ -608,11 +617,6 @@ pub async fn run_agent( .and_then(|fs| fs.chat_input_enabled) .unwrap_or(false); - let memory_id = flow_context - .flow_status - .as_ref() - .and_then(|fs| fs.memory_id); - let step_name = get_step_name_from_flow(summary.as_deref(), job.flow_step_id.as_deref()); let max_iterations = args @@ -978,37 +982,33 @@ pub async fn run_agent( // Skip memory persistence if using manual messages (bypass memory entirely) // final_messages contains the complete history (old messages + new ones) if matches!(output_type, OutputType::Text) && !use_manual_messages { - if let Some(Memory::Auto { context_length }) = &args.memory { - if *context_length > 0 { - if let Some(step_id) = job.flow_step_id.as_deref() { - // Extract OpenAIMessages from final_messages - let all_messages: Vec = - final_messages.iter().map(|m| m.message.clone()).collect(); + if let Some(Memory::Auto { context_length, .. }) = &args.memory { + if let Some(step_id) = job.flow_step_id.as_deref() { + // Extract OpenAIMessages from final_messages + let all_messages: Vec = + final_messages.iter().map(|m| m.message.clone()).collect(); - if !all_messages.is_empty() { - // Keep only the last n messages - let start_idx = all_messages.len().saturating_sub(*context_length); - let messages_to_persist = all_messages[start_idx..].to_vec(); + if !all_messages.is_empty() { + // Keep only the last n messages + let start_idx = all_messages.len().saturating_sub(*context_length); + let messages_to_persist = all_messages[start_idx..].to_vec(); - if let Some(memory_id) = - flow_context.flow_status.and_then(|fs| fs.memory_id) + if let Some(memory_id) = memory_id { + if let Err(e) = write_to_memory( + db, + &job.workspace_id, + memory_id, + step_id, + &messages_to_persist, + ) + .await { - if let Err(e) = write_to_memory( - db, - &job.workspace_id, - memory_id, + tracing::error!( + "Failed to persist {} messages to memory for step {}: {}", + messages_to_persist.len(), step_id, - &messages_to_persist, - ) - .await - { - tracing::error!( - "Failed to persist {} messages to memory for step {}: {}", - messages_to_persist.len(), - step_id, - e - ); - } + e + ); } } } diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 8955be2e4a..259d9c1cf9 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -42,6 +42,7 @@ export interface SchemaProperty { properties?: { [name: string]: SchemaProperty } required?: string[] showExpr?: string + hideWhenChatEnabled?: boolean password?: boolean order?: string[] nullable?: boolean @@ -52,6 +53,7 @@ export interface SchemaProperty { originalType?: string disabled?: boolean 'x-no-s3-storage-workspace-warning'?: string + 'x-auto-generate'?: boolean } export interface ModalSchemaProperty { diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 078585cda3..2537e249fe 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -47,6 +47,7 @@ import AIProviderPicker from './AIProviderPicker.svelte' import TextInput from './text_input/TextInput.svelte' import FileInput from './common/fileInput/FileInput.svelte' + import { randomUUID } from './flows/conversations/FlowChatManager.svelte' interface Props { label?: string @@ -122,6 +123,7 @@ | undefined workspace?: string | undefined s3StorageConfigured?: boolean + chatInputEnabled?: boolean actions?: import('svelte').Snippet innerBottomSnippet?: import('svelte').Snippet fieldHeaderActions?: import('svelte').Snippet @@ -182,6 +184,7 @@ computeS3ForceViewerPolicies = undefined, workspace = undefined, s3StorageConfigured = true, + chatInputEnabled = false, actions, innerBottomSnippet, fieldHeaderActions, @@ -255,7 +258,7 @@ nvalue = structuredClone($state.snapshot(defaultValue)) if (defaultValue === undefined || defaultValue === null) { if (inputCat === 'string') { - nvalue = nullable ? null : '' + nvalue = nullable ? null : format === 'uuid' && extra?.['x-auto-generate'] ? randomUUID() : '' } else if (inputCat == 'enum' && required) { let firstV = enum_?.[0] if (typeof firstV === 'string') { @@ -1143,6 +1146,7 @@ {disablePortal} {disabled} {prettifyHeader} + {chatInputEnabled} hiddenArgs={['label', 'kind']} schema={{ properties: obj.properties, diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 2dc5672d25..9cfeecf9da 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -69,6 +69,7 @@ helperScript?: DynamicInputTypes.HelperScript | undefined isAgentTool?: boolean s3StorageConfigured?: boolean + chatInputEnabled?: boolean } let { @@ -94,7 +95,8 @@ otherArgs = {}, helperScript = undefined, isAgentTool = false, - s3StorageConfigured = true + s3StorageConfigured = true, + chatInputEnabled = false }: Props = $props() let monaco: SimpleEditor | undefined = $state(undefined) @@ -338,6 +340,19 @@ otherArgs: Record ) { const schemaProperty = schema?.properties?.[argName] + + if (schemaProperty?.hideWhenChatEnabled && chatInputEnabled) { + if (!hidden) { + hidden = true + if (arg) { + arg.value = undefined + arg.expr = undefined + } + inputCheck = true + } + return + } + if (schemaProperty?.showExpr) { // Build args object with current field value and other context const currentValue = propertyType === 'static' ? arg?.value : arg?.expr @@ -802,6 +817,7 @@ bind:placeholder={schema.properties[argName].placeholder} {helperScript} {s3StorageConfigured} + {chatInputEnabled} otherArgs={Object.fromEntries( Object.entries(otherArgs).map(([key, transform]) => [ key, diff --git a/frontend/src/lib/components/InputTransformSchemaForm.svelte b/frontend/src/lib/components/InputTransformSchemaForm.svelte index 381bf2f121..afb86a5a9e 100644 --- a/frontend/src/lib/components/InputTransformSchemaForm.svelte +++ b/frontend/src/lib/components/InputTransformSchemaForm.svelte @@ -26,6 +26,7 @@ class?: string helperScript?: DynamicInputTypes.HelperScript isAgentTool?: boolean + chatInputEnabled?: boolean } let { @@ -40,7 +41,8 @@ enableAi = false, class: clazz = '', helperScript = undefined, - isAgentTool = false + isAgentTool = false, + chatInputEnabled = false }: Props = $props() let inputCheck: { [id: string]: boolean } = $state({}) @@ -140,6 +142,7 @@ {helperScript} {isAgentTool} {s3StorageConfigured} + {chatInputEnabled} otherArgs={Object.fromEntries( Object.entries(args ?? {}).filter(([key]) => key !== argName) )} diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 16494af9b9..fefe6b4656 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -70,6 +70,7 @@ | undefined) | undefined workspace?: string | undefined + chatInputEnabled?: boolean actions?: import('svelte').Snippet<[{ item: { id: string; value: string } }]> | undefined } @@ -112,6 +113,7 @@ lightHeaderFont = false, computeS3ForceViewerPolicies = undefined, workspace = undefined, + chatInputEnabled = false, actions: actions_render = undefined }: Props = $props() @@ -206,8 +208,17 @@ function handleHiddenFields(schema: Schema | any, args: Record) { for (const x of fields) { - if (schema?.properties?.[x.value]?.showExpr) { - if (computeShow(x.value, schema.properties?.[x.value]?.showExpr, args)) { + const prop = schema?.properties?.[x.value] + if (prop?.hideWhenChatEnabled && chatInputEnabled) { + if (!hidden[x.value]) { + hidden[x.value] = true + delete args[x.value] + inputCheck[x.value] = true + } + continue + } + if (prop?.showExpr) { + if (computeShow(x.value, prop.showExpr, args)) { hidden[x.value] = false } else if (!hidden[x.value]) { hidden[x.value] = true @@ -235,13 +246,18 @@ ;[schema, args] if (args && typeof args == 'object') { - let oneShowExpr = false + let hasShowExpr = false + let hasHideWhenChatEnabled = false for (const key of fields) { - if (schema?.properties?.[key.value]?.showExpr) { - oneShowExpr = true + const prop = schema?.properties?.[key.value] + if (prop?.showExpr) { + hasShowExpr = true + } + if (prop?.hideWhenChatEnabled && chatInputEnabled) { + hasHideWhenChatEnabled = true } } - if (!oneShowExpr) { + if (!hasShowExpr && !hasHideWhenChatEnabled) { return } for (const key in args) { diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 9d7849e40e..213dd46b59 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -611,6 +611,7 @@ {enableAi} {isAgentTool} helperScript={retrieveDynCodeAndLang(flowModule.value)} + chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} /> diff --git a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts index 014056ea44..e4dce5eec1 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/FlowChatManager.svelte.ts @@ -88,7 +88,6 @@ export class FlowChatManager { } focusInput() { - console.log('focusInput', this.inputElement) this.inputElement?.focus() } diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index a750aa687b..cfd58ea5ce 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -38,6 +38,17 @@ export const AI_AGENT_SCHEMA: Schema = { description: 'Configure how conversation memory is managed. Choose "auto" to let Windmill automatically store and load messages (up to N last messages), or "manual" to provide an explicit array of conversation messages. The system_prompt and user_message are added to the messages if provided.', oneOf: [ + { + type: 'object', + title: 'off', + properties: { + kind: { + type: 'string', + enum: ['off'], + description: 'Disable conversation memory' + } + } + }, { type: 'object', title: 'auto', @@ -52,7 +63,15 @@ export const AI_AGENT_SCHEMA: Schema = { type: 'number', description: 'Number of most recent messages to store and load. Set to 0 to disable memory.', - default: 0 + default: 5 + }, + memory_id: { + type: 'string', + format: 'uuid', + 'x-auto-generate': true, + description: + 'Custom memory identifier. Each unique ID maintains separate conversation history.', + hideWhenChatEnabled: true } }, required: ['kind'], diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 1ee6ef454f..3ac0289676 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -95,6 +95,7 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) { } import { dfs as dfsApply } from './dfs' +import { randomUUID } from './conversations/FlowChatManager.svelte' export function cleanFlow(flow: OpenFlow | any): OpenFlow & { tag?: string @@ -122,6 +123,28 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & { if (mod.value.type == 'rawscript' && mod.value.assets?.length == 0) { mod.value.assets = undefined } + // Generate memory_id for AI agents with auto memory if not already set + // Only if chat input is not enabled, as otherwise memory id is based on conversation id + if (!newFlow.value.chat_input_enabled && mod.value.type === 'aiagent') { + const memoryTransform = mod.value.input_transforms?.memory + if (memoryTransform?.type === 'static' && memoryTransform.value) { + const memoryValue = memoryTransform.value as { + kind: string + context_length: number + memory_id: string + } + if ( + memoryValue.kind === 'auto' && + memoryValue.context_length > 0 && + !memoryValue.memory_id + ) { + memoryTransform.value = { + ...memoryValue, + memory_id: randomUUID() + } + } + } + } }) if (newFlow.value.concurrency_key == '') { newFlow.value.concurrency_key = undefined