fix: address review on cleared test history and zero-count memory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-16 11:45:41 +02:00
co-authored by Claude Fable 5.1
parent 74bcf90ca0
commit 0d02a31555
14 changed files with 52 additions and 23 deletions
+4 -1
View File
@@ -654,7 +654,9 @@ pub async fn handle_ai_agent_job(
};
// Only after interpolating the resource: these are caller-controlled and already resolved by
// build_args_map, so passing them through it again would expand contextual values —
// `$WM_TOKEN` in a user message would reach the model provider.
// `$WM_TOKEN` in a user message would reach the model provider. The resource is not
// validated against a schema, so a flow-local key it happens to carry is dropped rather
// than read as the step's.
for key in [
"user_message",
"user_attachments",
@@ -662,6 +664,7 @@ pub async fn handle_ai_agent_job(
"memory_id",
"previous_messages",
] {
brain.remove(key);
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -68,7 +68,7 @@ The worker reconciles them once per agent invocation, nested agent tools include
1. A legacy `auto` or `manual` memory: read as the editor that wrote it ran it. `manual` replays
its list; `auto` uses the run's memory id, else the id baked into it, else runs stateless.
Neither history input is read.
Neither history input is read. An `auto` without a count, or with 0, is off and read as such.
2. Managed memory: the memory id is the step's, else the run's. With no memory id the agent runs
stateless, and a step `previous_messages` is ignored.
3. Memory off: the history is `previous_messages`, else nothing. Memory is neither read nor
+11 -11
View File
@@ -175,17 +175,19 @@
// it carries every brain key as undefined even though the form renders only the flow-local
// ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just
// supplied with nothing, so an inlined step takes only the inputs its form actually offers.
// A history input left blank here stays as the step authored it: turned into an expression that
// evaluates to nothing, it would read as a memory id set to empty, where the step's own blank
// static value reads as unset.
// A history input left blank here is unset, as a blank static value is on the step: sent as an
// expression that evaluates to nothing it would read as a memory id set to empty, and left to
// the step's own transform it would reuse a value the author just cleared.
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const formKeys = (
draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
).filter(
(key) =>
!(AGENT_HISTORY_KEYS as readonly string[]).includes(key) ||
(args[key] != undefined &&
args[key] !== '' &&
!(Array.isArray(args[key]) && !args[key].length))
(key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
const stepTransforms = Object.fromEntries(
Object.entries((agentVal.input_transforms ?? {}) as Record<string, InputTransform>).filter(
([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
)
// The test form only covers the schema it was given, and for a standalone agent that may be
@@ -194,9 +196,7 @@
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent
? {}
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
...(agentVal.agent ? {} : stepTransforms),
...Object.fromEntries(
formKeys.map((key) => [
key,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -111,6 +111,9 @@ describe('memoryOptionLabel', () => {
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()
})
})
@@ -16,6 +16,11 @@ export const MEMORY_OPTION_LABELS: Record<string, string> = {
}
export function memoryOptionLabel(memory: any): string | undefined {
// Managed memory that keeps no messages runs as off, and a note about what that state reads
// must say so.
if ((memory?.kind === 'window' || memory?.kind === 'auto') && !memory.context_length) {
return MEMORY_OPTION_LABELS.off
}
return memory?.kind ? MEMORY_OPTION_LABELS[memory.kind] : undefined
}
@@ -72,7 +77,7 @@ export const AI_AGENT_SCHEMA: Schema = {
context_length: {
type: 'number',
title: 'Messages to keep',
description: 'Number of most recent messages to load and store.',
description: 'Number of most recent messages to load and store. 0 turns memory off.',
default: 10
}
},
@@ -154,7 +154,8 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & {
* A chat flow runs with the conversation as its memory id, so an id an older editor baked into a
* step's memory was never read there and is dropped. Anywhere else it still applies to runs that
* pass none, and stays until the author converts it. An empty static memory id or message list
* reads as unset at runtime, so neither is persisted.
* reads as unset at runtime, so neither is persisted, and managed memory that keeps no messages
* runs as off, so it is saved as off.
*/
export function normalizeAgentHistory(
inputTransforms: Record<string, any> | undefined,
@@ -162,6 +163,13 @@ export function normalizeAgentHistory(
) {
if (!inputTransforms) return
const memory = inputTransforms.memory
if (
memory?.type === 'static' &&
memory.value?.kind === 'window' &&
!memory.value.context_length
) {
memory.value = { kind: 'off' }
}
if (
memory?.type === 'static' &&
memory.value?.kind === 'auto' &&
@@ -78,6 +78,16 @@ describe('normalizeAgentHistory', () => {
expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 })
})
it('saves managed memory that keeps no messages as off, which is how it runs', () => {
for (const context_length of [0, null, undefined]) {
const transforms: Record<string, any> = {
memory: { type: 'static', value: { kind: 'window', context_length } }
}
normalizeAgentHistory(transforms, false)
expect(transforms.memory.value).toEqual({ kind: 'off' })
}
})
it('does not persist an empty static memory id or message list', () => {
const transforms: Record<string, any> = {
memory_id: { type: 'static', value: ' ' },
+1 -1
View File
@@ -556,7 +556,7 @@ components:
description: |
Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.
The step's own `memory_id` is not read while this kind is set; switch the kind to `window`
to use it.
to use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.
properties:
kind:
type: string
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long