mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +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>
196 lines
6.9 KiB
TypeScript
196 lines
6.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
agentConfigToInputTransforms,
|
||
agentEditorRefusal,
|
||
flowLocalInputs,
|
||
inputTransformsToAgentConfig,
|
||
nonStaticBrainKeys,
|
||
summarizeAgentBrain,
|
||
toolInputOverrides,
|
||
transformValuedBrainKeys
|
||
} from './agentResourceUtils'
|
||
|
||
describe('transformValuedBrainKeys', () => {
|
||
// The tag is the whole test: `{type:'ai'}` carries no payload key to look for.
|
||
it('flags every transform variant, whatever it carries', () => {
|
||
expect(
|
||
transformValuedBrainKeys({
|
||
system_prompt: { type: 'ai' },
|
||
temperature: { type: 'javascript', expr: 'flow_input.t' },
|
||
max_iterations: { type: 'static', value: 3 }
|
||
})
|
||
).toEqual(['system_prompt', 'temperature', 'max_iterations'])
|
||
})
|
||
|
||
it('leaves the brain values that are legitimately objects', () => {
|
||
expect(
|
||
transformValuedBrainKeys({
|
||
output_schema: { type: 'object', properties: {} },
|
||
memory: { kind: 'auto', context_length: 20 },
|
||
provider: { kind: 'openai', model: 'gpt-4o' },
|
||
system_prompt: 'hi'
|
||
})
|
||
).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('agentEditorRefusal', () => {
|
||
it('opens an agent', () => {
|
||
expect(agentEditorRefusal('u/admin/agent', 'ai_agent')).toBeUndefined()
|
||
})
|
||
|
||
// A path with no deployed row answers with its own draft, and the generic resource editor writes
|
||
// no type: assuming `ai_agent` there would create that path as an agent on deploy.
|
||
it('refuses an unknown type rather than assuming one', () => {
|
||
expect(agentEditorRefusal('u/admin/db', undefined)).toContain('no deployed agent')
|
||
})
|
||
})
|
||
|
||
describe('summarizeAgentBrain', () => {
|
||
it('returns only set fields, in brain-key order, formatted', () => {
|
||
const rows = summarizeAgentBrain({
|
||
provider: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/x/openai' } as any,
|
||
system_prompt: 'You are helpful',
|
||
temperature: 0.7,
|
||
streaming: true,
|
||
max_iterations: 10
|
||
})
|
||
expect(rows).toEqual([
|
||
{ label: 'Provider', value: 'openai · gpt-4o' },
|
||
{ label: 'System message', value: 'You are helpful' },
|
||
{ label: 'Stream the response', value: 'on' },
|
||
{ label: 'Temperature', value: '0.7' },
|
||
{ label: 'Max iterations', value: '10' }
|
||
])
|
||
})
|
||
|
||
it('skips empty/undefined fields', () => {
|
||
expect(summarizeAgentBrain({ system_prompt: '', provider: undefined as any })).toEqual([])
|
||
expect(summarizeAgentBrain(undefined)).toEqual([])
|
||
})
|
||
|
||
it('summarizes structured fields compactly', () => {
|
||
// memory is serialized with a `kind` tag (serde tag = "kind")
|
||
const rows = summarizeAgentBrain({
|
||
memory: { kind: 'auto', context_length: 20 } as any,
|
||
output_schema: { type: 'object' } as any
|
||
})
|
||
expect(rows).toEqual([
|
||
{ label: 'Managed memory', value: 'Last 20 messages' },
|
||
{ label: 'Output schema', value: 'configured' }
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('inputTransformsToAgentConfig', () => {
|
||
it('captures only static brain values and carries tools', () => {
|
||
const config = inputTransformsToAgentConfig(
|
||
{
|
||
provider: { type: 'static', value: { kind: 'openai' } },
|
||
system_prompt: { type: 'static', value: 'hi' },
|
||
temperature: { type: 'javascript', expr: 'flow_input.t' }, // non-static → dropped
|
||
max_iterations: { type: 'static', value: undefined }, // undefined → dropped
|
||
user_message: { type: 'static', value: 'hello' } // not a brain key → dropped
|
||
} as any,
|
||
[{ id: 't1' }] as any
|
||
)
|
||
expect(config).toEqual({
|
||
tools: [{ id: 't1' }],
|
||
provider: { kind: 'openai' },
|
||
system_prompt: 'hi'
|
||
})
|
||
})
|
||
|
||
it('defaults tools to []', () => {
|
||
expect(inputTransformsToAgentConfig({}, undefined)).toEqual({ tools: [] })
|
||
})
|
||
})
|
||
|
||
describe('agentConfigToInputTransforms', () => {
|
||
it('wraps brain values as static transforms and round-trips through the config', () => {
|
||
const config = { provider: { kind: 'openai' }, system_prompt: 'hi', tools: [{ id: 't1' }] }
|
||
const its = agentConfigToInputTransforms(config as any)
|
||
expect(its).toEqual({
|
||
provider: { type: 'static', value: { kind: 'openai' } },
|
||
system_prompt: { type: 'static', value: 'hi' }
|
||
})
|
||
const back = inputTransformsToAgentConfig(its, config.tools as any)
|
||
expect(back).toEqual(config)
|
||
})
|
||
})
|
||
|
||
describe('nonStaticBrainKeys', () => {
|
||
it('lists brain keys with a non-static transform, in brain-key order', () => {
|
||
expect(
|
||
nonStaticBrainKeys({
|
||
provider: { type: 'static', value: {} },
|
||
temperature: { type: 'connected' },
|
||
system_prompt: { type: 'javascript', expr: 'x' }
|
||
} as any)
|
||
).toEqual(['system_prompt', 'temperature'])
|
||
})
|
||
|
||
it('flags a non-static provider (the save-blocking case) but not a static one', () => {
|
||
// provider is required on the resource; a non-static one gets dropped and must block saving
|
||
expect(nonStaticBrainKeys({ provider: { type: 'javascript', expr: 'x' } } as any)).toContain(
|
||
'provider'
|
||
)
|
||
expect(nonStaticBrainKeys({ provider: { type: 'static', value: {} } } as any)).not.toContain(
|
||
'provider'
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('flowLocalInputs', () => {
|
||
it('keeps the step’s own inputs, dropping brain transforms', () => {
|
||
expect(
|
||
flowLocalInputs({
|
||
provider: { type: 'static', value: {} },
|
||
memory: { type: 'static', value: { kind: 'window', context_length: 10 } },
|
||
user_message: { type: 'static', value: 'hi' },
|
||
user_attachments: { type: 'static', value: [] },
|
||
memory_id: { type: 'javascript', expr: 'flow_input.customer_id' },
|
||
previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] },
|
||
// The roster it narrows belongs to the agent, but which of it one flow may call does
|
||
// not: saving this into the resource would impose it on every flow linking the agent.
|
||
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
|
||
} as any)
|
||
).toEqual({
|
||
user_message: { type: 'static', value: 'hi' },
|
||
user_attachments: { type: 'static', value: [] },
|
||
memory_id: { type: 'javascript', expr: 'flow_input.customer_id' },
|
||
previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] },
|
||
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
|
||
})
|
||
})
|
||
|
||
it('handles undefined', () => {
|
||
expect(flowLocalInputs(undefined)).toEqual({})
|
||
})
|
||
})
|
||
|
||
describe('toolInputOverrides', () => {
|
||
const base = {
|
||
tenant: { type: 'javascript', expr: 'flow_input.tenant' },
|
||
query: { type: 'static', value: 'x' }
|
||
} as any
|
||
|
||
it('returns nothing when inputs equal the resource base (opening a tool is a no-op)', () => {
|
||
expect(toolInputOverrides(base, base)).toEqual({})
|
||
})
|
||
|
||
it('returns only the edited keys, so a revert to base drops back to nothing', () => {
|
||
const edited = { ...base, query: { type: 'static', value: 'y' } }
|
||
expect(toolInputOverrides(edited, base)).toEqual({ query: { type: 'static', value: 'y' } })
|
||
// reverting query back to the base value yields an empty override set again
|
||
expect(toolInputOverrides(base, base)).toEqual({})
|
||
})
|
||
|
||
it('includes keys absent from the base', () => {
|
||
expect(toolInputOverrides({ extra: { type: 'static', value: 1 } } as any, base)).toEqual({
|
||
extra: { type: 'static', value: 1 }
|
||
})
|
||
})
|
||
})
|