diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index fd09013486..f250c09606 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -85,7 +85,7 @@ pub enum Memory { Auto { #[serde(default)] context_length: usize, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_blank_as_none")] memory_id: Option, }, /// Written before the step's `messages` input, which it is equivalent to. @@ -94,6 +94,19 @@ pub enum Memory { }, } +// An editor form can leave `""` in a legacy baked id it never filled; it means no id rather than +// failing every run of the step. +fn deserialize_blank_as_none<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + match as serde::Deserialize>::deserialize(deserializer)? { + Some(id) if !id.trim().is_empty() => Uuid::parse_str(id.trim()) + .map(Some) + .map_err(serde::de::Error::custom), + _ => Ok(None), + } +} + fn deserialize_present_messages<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result>>, D::Error> { diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index e1a931bf7e..11a1b52f91 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1978,6 +1978,18 @@ mod tests { None, Resolved::Window(baked, 4), ), + ( + "legacy auto with an empty baked id uses the run's", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": "" } }), + Some(run), + Resolved::Window(run, 4), + ), + ( + "legacy auto with an empty baked id and no run id is stateless", + json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": " " } }), + None, + Resolved::Stateless { noted: true }, + ), ( "legacy auto without a length is off", json!({ "memory": { "kind": "auto", "memory_id": baked } }), diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index d3112beb3b..ebdd1ef4ac 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -115,5 +115,10 @@ describe('memoryPropertyFor', () => { 'auto' ]) expect(kinds({ kind: 'manual', messages: [] })).toEqual(['off', 'window', 'manual']) + const autoVariant = (value: unknown) => memoryPropertyFor(property, value).oneOf.at(-1) + expect(autoVariant({ kind: 'auto', context_length: 4 }).properties.memory_id).toBeUndefined() + expect( + autoVariant({ kind: 'auto', context_length: 4, memory_id: 'x' }).properties.memory_id + ).toBeDefined() }) }) diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index 8a344a370d..bd2c1f8e40 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -213,8 +213,15 @@ export const LEGACY_MEMORY_VARIANTS: Record = { * value holds it. Otherwise the property itself is returned, which callers compare by identity to * avoid rebuilding the step schema. */ export function memoryPropertyFor(property: any, value: any): any { - const legacy = value?.kind ? LEGACY_MEMORY_VARIANTS[value.kind] : undefined + let legacy = value?.kind ? LEGACY_MEMORY_VARIANTS[value.kind] : undefined if (!legacy || !property?.oneOf) return property + // The form fills an empty string field with `''` when it opens, so the baked id field is only + // offered to a value saved with the key. Keyed on presence rather than content, or clearing the + // id to retype it would remove the field mid-edit. + if (value.kind === 'auto' && !('memory_id' in value)) { + const { memory_id: _, ...properties } = legacy.properties + legacy = { ...legacy, properties } + } return { ...property, oneOf: [...property.oneOf, legacy] } } diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index c6d625e28e..17afa2c6c6 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -163,10 +163,10 @@ export function normalizeAgentHistory( if (!inputTransforms) return const memory = inputTransforms.memory if ( - chatInputEnabled && memory?.type === 'static' && memory.value?.kind === 'auto' && - memory.value.memory_id + 'memory_id' in memory.value && + (chatInputEnabled || !String(memory.value.memory_id ?? '').trim()) ) { const { memory_id: _, ...policy } = memory.value memory.value = policy diff --git a/frontend/src/lib/components/flows/utils.test.ts b/frontend/src/lib/components/flows/utils.test.ts index 7239986879..04b24a7cc7 100644 --- a/frontend/src/lib/components/flows/utils.test.ts +++ b/frontend/src/lib/components/flows/utils.test.ts @@ -70,6 +70,14 @@ describe('normalizeAgentHistory', () => { expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 }) }) + it('drops an empty baked memory id, which names no memory', () => { + const transforms = { + memory: { type: 'static', value: { kind: 'auto', context_length: 10, memory_id: '' } } + } + normalizeAgentHistory(transforms, false) + expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 }) + }) + it('does not persist an empty static memory id or message list', () => { const transforms: Record = { memory_id: { type: 'static', value: ' ' },