mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
* feat: split ai agent memory into agent policy, run memory id and step history * fix: scope string memory ids to workspace and flow, keep nested tool history inputs * chore: update sqlx cache for the flow context query * docs: describe memory id scoping as collision-free rather than isolated * chore: regenerate openflow json after merging main * fix: offer no memory id for legacy manual memory, document linked history inputs * fix: seed provided messages from legacy manual memory and hide its note once set * fix: bypass memory when a provided messages expression evaluates to null * fix: require a user message when provided messages are empty * chore: keep the empty messages comment within the line width * docs: name the history inputs wherever linked steps list their flow-local inputs * docs: keep the memory storage path on one line * feat: managed memory with an inherited or custom memory id per step Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: list a custom memory id in the test run form and name where an inherited one comes from Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep memory id out of the add-field menu and drop the memory id telemetry Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep legacy auto memory without an id working after an untouched redeploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: rename step messages to previous_messages and address review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: rewrap comments and docs lines lengthened by the previous_messages rename Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: read agent memory as either a legacy shape or the current one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: name the memory setting in ignored-input notes and keep conversions honest Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep a legacy memory count unset on open and read a cleared count as off Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: address review on cleared test history and zero-count memory Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: drop flow-local keys from a linked agent resource before interpolating it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: keep a linked resource's own inputs as fallbacks and note ignored history on image runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: restore the linked agent draft tests and log ignored history on every image run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: resolve the one-of variant from the value when the selected one leaves the list Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: treat zero-count managed memory as off when enabling chat mode and shorten comments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: stop requiring user_message in the openflow agent contract when previous messages are the prompt Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { AI_AGENT_SCHEMA, memoryOptionLabel, memoryPropertyFor } from './flowInfers'
|
|
import {
|
|
AGENT_FIELD_BY_KEY,
|
|
AGENT_FIELDS,
|
|
agentMemoryMode,
|
|
historyInputApplies,
|
|
agentFieldIsSet,
|
|
initialVisibleAgentFields
|
|
} from './agentFormFields'
|
|
|
|
const schemaProperties = AI_AGENT_SCHEMA.properties ?? {}
|
|
|
|
function set(key: string, transform: unknown): boolean {
|
|
return agentFieldIsSet(AGENT_FIELD_BY_KEY[key], transform)
|
|
}
|
|
|
|
describe('agentFieldIsSet', () => {
|
|
it('reads the seeded placeholder as unset', () => {
|
|
expect(set('temperature', { type: 'static', value: undefined })).toBe(false)
|
|
expect(set('temperature', undefined)).toBe(false)
|
|
// The same placeholder after a round trip through the API.
|
|
expect(set('temperature', { type: 'static', value: null })).toBe(false)
|
|
})
|
|
|
|
it('treats a value the runtime cannot tell from absence as unset', () => {
|
|
expect(set('output_type', { type: 'static', value: 'text' })).toBe(false)
|
|
expect(set('max_iterations', { type: 'static', value: 10 })).toBe(false)
|
|
expect(set('memory', { type: 'static', value: { kind: 'off' } })).toBe(false)
|
|
expect(set('user_attachments', { type: 'static', value: [] })).toBe(false)
|
|
})
|
|
|
|
it('reads streaming as unset while it is on, which is what an absent key does', () => {
|
|
expect(schemaProperties.streaming?.default).toBe(true)
|
|
expect(set('streaming', { type: 'static', value: true })).toBe(false)
|
|
expect(set('streaming', { type: 'static', value: false })).toBe(true)
|
|
})
|
|
|
|
it('reads anything the user authored as set', () => {
|
|
expect(set('temperature', { type: 'static', value: 0 })).toBe(true)
|
|
// An empty `enabled_tools` advertises no tools, so it is a choice rather than an unset field:
|
|
// giving the spec an `implicit` of `[]` would hide the row while the run still carries none.
|
|
expect(set('enabled_tools', { type: 'static', value: [] })).toBe(true)
|
|
expect(set('output_type', { type: 'static', value: 'image' })).toBe(true)
|
|
expect(set('memory', { type: 'static', value: { kind: 'auto', context_length: 5 } })).toBe(true)
|
|
expect(set('max_iterations', { type: 'javascript', expr: 'flow_input.loops' })).toBe(true)
|
|
expect(set('max_iterations', { type: 'javascript', expr: '' })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('initialVisibleAgentFields', () => {
|
|
it('shows only the core fields for a step that materialized every default', () => {
|
|
// What the pre-redesign form wrote on first render of a blank agent step.
|
|
const legacy = {
|
|
provider: { type: 'static', value: { kind: 'openai', resource: '$res:u/admin/openai' } },
|
|
output_type: { type: 'static', value: 'text' },
|
|
user_message: { type: 'static', value: undefined },
|
|
system_prompt: { type: 'static', value: undefined },
|
|
streaming: { type: 'static', value: true },
|
|
memory: { type: 'static', value: { kind: 'off' } },
|
|
output_schema: { type: 'static', value: null },
|
|
user_attachments: { type: 'static', value: [] },
|
|
max_completion_tokens: { type: 'static', value: null },
|
|
temperature: { type: 'static', value: null },
|
|
max_iterations: { type: 'static', value: 10 }
|
|
}
|
|
expect([...initialVisibleAgentFields(legacy, schemaProperties)].sort()).toEqual([
|
|
'provider',
|
|
'system_prompt',
|
|
'tools',
|
|
'user_message'
|
|
])
|
|
})
|
|
|
|
it('collapses a linked step to its flow-local inputs', () => {
|
|
const linkedSchema = {
|
|
user_message: schemaProperties.user_message,
|
|
user_attachments: schemaProperties.user_attachments
|
|
}
|
|
expect([...initialVisibleAgentFields({}, linkedSchema)]).toEqual(['user_message'])
|
|
})
|
|
|
|
it('covers every schema key, so no field can only be reached through the raw doc', () => {
|
|
const registered = new Set<string>(AGENT_FIELDS.map((f) => f.key))
|
|
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('historyInputApplies', () => {
|
|
// Mirrors the worker: offering a step input a run would ignore misleads the author.
|
|
it('offers each history input in its own memory mode, and neither on an older setting', () => {
|
|
expect(agentMemoryMode(undefined)).toBe('off')
|
|
expect(agentMemoryMode({ kind: 'window', context_length: 0 })).toBe('off')
|
|
expect(agentMemoryMode({ kind: 'window', context_length: 10 })).toBe('managed')
|
|
expect(agentMemoryMode({ kind: 'manual', messages: [] })).toBe('legacy')
|
|
expect(agentMemoryMode({ kind: 'auto', context_length: 4, memory_id: 'x' })).toBe('legacy')
|
|
expect(agentMemoryMode({ kind: 'auto' })).toBe('off')
|
|
expect(historyInputApplies('memory_id', 'managed')).toBe(true)
|
|
expect(historyInputApplies('previous_messages', 'managed')).toBe(false)
|
|
expect(historyInputApplies('memory_id', 'off')).toBe(false)
|
|
expect(historyInputApplies('previous_messages', 'off')).toBe(true)
|
|
expect(historyInputApplies('memory_id', 'legacy')).toBe(false)
|
|
expect(historyInputApplies('previous_messages', 'legacy')).toBe(false)
|
|
expect(historyInputApplies('previous_messages', undefined)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('memoryOptionLabel', () => {
|
|
// The ignored-input note names the setting by the same label its own button carries.
|
|
it('names each memory option the way the field renders it', () => {
|
|
expect(memoryOptionLabel({ kind: 'manual', messages: [] })).toBe('Previous messages (legacy)')
|
|
expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('On (legacy)')
|
|
expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('On')
|
|
// Keeping no messages runs as off, whichever kind says so.
|
|
expect(memoryOptionLabel({ kind: 'window', context_length: 0 })).toBe('Off')
|
|
expect(memoryOptionLabel({ kind: 'auto' })).toBe('Off')
|
|
expect(memoryOptionLabel(undefined)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('memoryPropertyFor', () => {
|
|
const property = schemaProperties.memory
|
|
const kinds = (value: unknown) =>
|
|
memoryPropertyFor(property, value).oneOf.map((variant: { title: string }) => variant.title)
|
|
|
|
it('adds a legacy kind as an option only while the value holds it', () => {
|
|
expect(memoryPropertyFor(property, { kind: 'window', context_length: 10 })).toBe(property)
|
|
expect(memoryPropertyFor(property, undefined)).toBe(property)
|
|
expect(kinds({ kind: 'auto', context_length: 4, memory_id: 'x' })).toEqual([
|
|
'off',
|
|
'window',
|
|
'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()
|
|
// A chat flow drops the baked id on save, so the form does not offer it there.
|
|
expect(
|
|
memoryPropertyFor(
|
|
property,
|
|
{ kind: 'auto', context_length: 4, memory_id: 'x' },
|
|
true
|
|
).oneOf.at(-1).properties.memory_id
|
|
).toBeUndefined()
|
|
})
|
|
})
|