mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: managed memory with an inherited or custom memory id per step
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
796bedbb68
commit
e225e67bdf
@@ -124,8 +124,8 @@ struct AIAgentArgsRaw {
|
||||
// nothing runs stateless instead of falling back to the run's memory id.
|
||||
#[serde(default, deserialize_with = "deserialize_present")]
|
||||
memory_id: Option<serde_json::Value>,
|
||||
// Same distinction for an authored messages expression: null sends no history, and memory stays
|
||||
// bypassed rather than being read and overwritten.
|
||||
// Same distinction for an authored messages expression: null replaces a legacy manual list with
|
||||
// no history, where an absent key keeps that list.
|
||||
#[serde(default, deserialize_with = "deserialize_present_messages")]
|
||||
messages: Option<Option<Vec<OpenAIMessage>>>,
|
||||
// Legacy field for backward compatibility
|
||||
|
||||
@@ -133,46 +133,65 @@ fn keep_authored_history_args(
|
||||
}
|
||||
match step_input_transforms.get("messages") {
|
||||
Some(InputTransform::Javascript { .. }) => {}
|
||||
Some(InputTransform::Static { value }) if value.get().trim() != "null" => {}
|
||||
Some(InputTransform::Static { .. })
|
||||
if args.messages.as_ref().is_some_and(|m| !m.is_empty()) => {}
|
||||
_ => args.messages = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles the step's history inputs, the agent's memory policy and the run's memory id, for
|
||||
/// every shape a flow or agent resource may still carry. Also returns a line for the job log when a
|
||||
/// policy that remembers ends up stateless.
|
||||
/// every shape a flow or agent resource may still carry. Managed memory reads only a memory id, and
|
||||
/// memory that is off reads only messages. Also returns lines for the job log: a step input that
|
||||
/// went unused, or a policy that remembers ending up stateless.
|
||||
fn resolve_history_source<'a>(
|
||||
args: &'a AIAgentArgs,
|
||||
run_memory_id: Option<Uuid>,
|
||||
workspace_id: &str,
|
||||
flow_path: &str,
|
||||
) -> (HistorySource<'a>, Option<&'static str>) {
|
||||
if let Some(messages) = &args.messages {
|
||||
return (HistorySource::Messages(messages), None);
|
||||
}
|
||||
) -> (HistorySource<'a>, Vec<&'static str>) {
|
||||
let mut notes = Vec::new();
|
||||
let (context_length, legacy_memory_id) = match &args.memory {
|
||||
Some(Memory::Manual { messages }) => return (HistorySource::Messages(messages), None),
|
||||
Some(Memory::Window { context_length }) => (*context_length, None),
|
||||
// An id baked in at save time only ever applied when the run carried none.
|
||||
Some(Memory::Auto { context_length, memory_id }) => (*context_length, *memory_id),
|
||||
Some(Memory::Off) | None => return (HistorySource::Stateless, None),
|
||||
Some(Memory::Manual { .. } | Memory::Off) | None => {
|
||||
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
|
||||
notes.push("Managed memory is off, so this step's memory id is ignored.");
|
||||
}
|
||||
let history = match (&args.messages, &args.memory) {
|
||||
(Some(messages), _) => HistorySource::Messages(messages),
|
||||
// The fixed list an older editor stored in `memory`, which the step's messages replace.
|
||||
(None, Some(Memory::Manual { messages })) => HistorySource::Messages(messages),
|
||||
_ => HistorySource::Stateless,
|
||||
};
|
||||
return (history, notes);
|
||||
}
|
||||
};
|
||||
let memory_id =
|
||||
match args.memory_id.as_deref() {
|
||||
Some("") => return (
|
||||
HistorySource::Stateless,
|
||||
Some("This step's memory id evaluated to an empty value, so the agent runs without memory."),
|
||||
),
|
||||
Some(step_memory_id) => memory_key(workspace_id, flow_path, step_memory_id),
|
||||
None => match run_memory_id.or(legacy_memory_id) {
|
||||
Some(memory_id) => memory_id,
|
||||
None => return (
|
||||
HistorySource::Stateless,
|
||||
Some("No memory id was passed to this run, so the agent runs without memory."),
|
||||
),
|
||||
},
|
||||
};
|
||||
(HistorySource::Window { memory_id, context_length }, None)
|
||||
if args
|
||||
.messages
|
||||
.as_ref()
|
||||
.is_some_and(|messages| !messages.is_empty())
|
||||
{
|
||||
notes.push("Managed memory is on, so this step's messages are ignored.");
|
||||
}
|
||||
let memory_id = match args.memory_id.as_deref() {
|
||||
Some("") => {
|
||||
notes.push(
|
||||
"This step's memory id evaluated to an empty value, so the agent runs without memory.",
|
||||
);
|
||||
return (HistorySource::Stateless, notes);
|
||||
}
|
||||
Some(step_memory_id) => memory_key(workspace_id, flow_path, step_memory_id),
|
||||
None => match run_memory_id.or(legacy_memory_id) {
|
||||
Some(memory_id) => memory_id,
|
||||
None => {
|
||||
notes
|
||||
.push("No memory id was passed to this run, so the agent runs without memory.");
|
||||
return (HistorySource::Stateless, notes);
|
||||
}
|
||||
},
|
||||
};
|
||||
(HistorySource::Window { memory_id, context_length }, notes)
|
||||
}
|
||||
|
||||
fn find_module_by_id(
|
||||
@@ -981,7 +1000,7 @@ pub async fn run_agent(
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.memory_id);
|
||||
let (history, history_note) = resolve_history_source(
|
||||
let (history, history_notes) = resolve_history_source(
|
||||
args,
|
||||
conversation_id,
|
||||
&job.workspace_id,
|
||||
@@ -1005,7 +1024,7 @@ pub async fn run_agent(
|
||||
let is_text_output = output_type == &OutputType::Text;
|
||||
|
||||
if matches!(output_type, OutputType::Text) {
|
||||
if let Some(note) = history_note {
|
||||
for note in history_notes {
|
||||
append_logs(&job.id, &job.workspace_id, format!("{note}\n"), conn).await;
|
||||
}
|
||||
match &history {
|
||||
@@ -1799,6 +1818,10 @@ mod tests {
|
||||
let cust_1 = Uuid::parse_str("0168fcea-ffa7-5c15-bdb0-7709bb5f540d").unwrap();
|
||||
let window = json!({ "kind": "window", "context_length": 10 });
|
||||
let message = json!([{ "role": "user", "content": "earlier" }]);
|
||||
let two_messages = json!([
|
||||
{ "role": "user", "content": "earlier" },
|
||||
{ "role": "assistant", "content": "reply" }
|
||||
]);
|
||||
let cases = [
|
||||
(
|
||||
"absent memory is off",
|
||||
@@ -1867,17 +1890,29 @@ mod tests {
|
||||
Resolved::Stateless { noted: true },
|
||||
),
|
||||
(
|
||||
"an off policy ignores the step memory id",
|
||||
"an off policy ignores the step memory id, and says so",
|
||||
json!({ "memory": { "kind": "off" }, "memory_id": "cust_1" }),
|
||||
Some(run),
|
||||
Resolved::Stateless { noted: false },
|
||||
Resolved::Stateless { noted: true },
|
||||
),
|
||||
(
|
||||
"provided messages win over memory",
|
||||
"managed memory ignores the step's messages",
|
||||
json!({ "memory": window, "memory_id": "cust_1", "messages": message }),
|
||||
Some(run),
|
||||
Resolved::Window(cust_1, 10),
|
||||
),
|
||||
(
|
||||
"memory that is off sends the step's messages",
|
||||
json!({ "messages": message }),
|
||||
Some(run),
|
||||
Resolved::Messages(1),
|
||||
),
|
||||
(
|
||||
"the step's messages replace a legacy manual list",
|
||||
json!({ "memory": { "kind": "manual", "messages": message }, "messages": two_messages }),
|
||||
Some(run),
|
||||
Resolved::Messages(2),
|
||||
),
|
||||
];
|
||||
for (name, history, run_memory_id, expected) in cases {
|
||||
let mut raw = json!({ "provider": { "kind": "openai", "resource": {}, "model": "m" } });
|
||||
@@ -1890,7 +1925,9 @@ mod tests {
|
||||
(HistorySource::Window { memory_id, context_length }, _) => {
|
||||
Resolved::Window(memory_id, context_length)
|
||||
}
|
||||
(HistorySource::Stateless, note) => Resolved::Stateless { noted: note.is_some() },
|
||||
(HistorySource::Stateless, notes) => {
|
||||
Resolved::Stateless { noted: !notes.is_empty() }
|
||||
}
|
||||
};
|
||||
assert_eq!(resolved, expected, "{name}");
|
||||
}
|
||||
@@ -1930,10 +1967,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provided messages bypass memory even when their expression evaluates to null; only a static
|
||||
/// placeholder leaves the step on its memory.
|
||||
/// Empty messages a form leaves on a step never replace a legacy list; an expression does, even
|
||||
/// when it evaluates to null.
|
||||
#[test]
|
||||
fn a_messages_expression_evaluating_to_null_bypasses_memory() {
|
||||
fn only_an_expression_can_empty_a_legacy_message_list() {
|
||||
let run = Uuid::from_u128(1);
|
||||
for (transform, expected) in [
|
||||
(
|
||||
@@ -1942,16 +1979,16 @@ mod tests {
|
||||
),
|
||||
(
|
||||
r#"{ "type": "static", "value": null }"#,
|
||||
Resolved::Window(run, 10),
|
||||
Resolved::Messages(1),
|
||||
),
|
||||
(
|
||||
r#"{ "type": "static", "value": [] }"#,
|
||||
Resolved::Messages(0),
|
||||
Resolved::Messages(1),
|
||||
),
|
||||
] {
|
||||
let mut args: AIAgentArgs = serde_json::from_value(serde_json::json!({
|
||||
"provider": { "kind": "openai", "resource": {}, "model": "m" },
|
||||
"memory": { "kind": "window", "context_length": 10 },
|
||||
"memory": { "kind": "manual", "messages": [{ "role": "user", "content": "earlier" }] },
|
||||
"messages": if transform.contains("[]") { serde_json::json!([]) } else { serde_json::Value::Null },
|
||||
}))
|
||||
.unwrap();
|
||||
@@ -1965,7 +2002,9 @@ mod tests {
|
||||
(HistorySource::Window { memory_id, context_length }, _) => {
|
||||
Resolved::Window(memory_id, context_length)
|
||||
}
|
||||
(HistorySource::Stateless, note) => Resolved::Stateless { noted: note.is_some() },
|
||||
(HistorySource::Stateless, notes) => {
|
||||
Resolved::Stateless { noted: !notes.is_empty() }
|
||||
}
|
||||
};
|
||||
assert_eq!(resolved, expected, "{transform}");
|
||||
}
|
||||
|
||||
Generated
+1
-1
File diff suppressed because one or more lines are too long
+22
-18
@@ -36,33 +36,37 @@ input editors (prop picker included) and a read-only view of its code — edits
|
||||
|
||||
## Memory
|
||||
|
||||
Memory is split between three owners, so a saved agent carries how much to remember and never
|
||||
Memory is split between three owners, so a saved agent carries whether it remembers and never
|
||||
which memory it is:
|
||||
|
||||
- **Agent: memory policy.** `memory` is a brain key, so it moves with a saved agent.
|
||||
`{ kind: window, context_length }` replays the last N messages and `{ kind: off }` keeps none.
|
||||
An absent `memory` means off. `auto` and `manual` are the older spellings and are still read.
|
||||
- **Agent: managed memory.** `memory` is a brain key, so it moves with a saved agent.
|
||||
`{ kind: window, context_length }` has Windmill store the conversation and replay its last N
|
||||
messages; `{ kind: off }` keeps none. An absent `memory` means off, the default: the editor turns
|
||||
it on when chat input is enabled. `auto` and `manual` are the older spellings and are still read.
|
||||
- **Run: memory id.** `flow_status.memory_id`, set when the run is queued: the chat conversation
|
||||
id, an app chat session id, or the `memory_id` run parameter. Any string is accepted, and one
|
||||
that is not a uuid is hashed to a v5 uuid scoped to the workspace and the flow the run started
|
||||
from (`memory_key` in `windmill-common/src/flow_conversations.rs`), so the same key in two flows
|
||||
names two memories. A uuid is used as is. Nothing is generated at save time, so schedules,
|
||||
webhooks, evals and plain runs pass no id and run stateless.
|
||||
- **Step: history inputs.** Flow-local, so they stay on a linked step. `memory_id` overrides the
|
||||
run's id, hashed the same way: a fixed value is one memory shared by every run, an expression such
|
||||
as `flow_input.customer_id` one memory per key, and an expression that evaluates to nothing runs
|
||||
stateless rather than falling back to the run's id. `messages` supplies the history itself and
|
||||
bypasses memory; an expression that evaluates to null sends no history and still bypasses it. The
|
||||
editor writes at most one of them and never seeds a placeholder for either, because a present key
|
||||
is the step's choice; if both are present, `messages` wins.
|
||||
- **Step: history inputs.** Flow-local, so they stay on a linked step. Each is read in one memory
|
||||
state only, and the editor offers it only there, the memory id behind a *Custom* toggle that
|
||||
writes the key only once it is on. With managed memory on, `memory_id` overrides
|
||||
the run's id, hashed the same way: a fixed value is one memory shared by every run, an expression
|
||||
such as `flow_input.customer_id` one memory per key, and an expression that evaluates to nothing
|
||||
runs stateless rather than falling back to the run's id. With memory off, `messages` supplies the
|
||||
history itself. The editor never seeds a placeholder for either, because a present key is the
|
||||
step's choice, and a static empty value reads as unset.
|
||||
|
||||
The worker reconciles them once per agent invocation, nested agent tools included, in
|
||||
`resolve_history_source` (`windmill-worker/src/ai_executor.rs`):
|
||||
|
||||
1. `messages`, or a legacy `manual` memory, is the history. Nothing is read or written.
|
||||
2. A policy that is off runs stateless.
|
||||
3. The memory id is the step's, else the run's, else a legacy id baked into the `auto` object.
|
||||
4. With no memory id the agent runs stateless and says so in the job log.
|
||||
1. Memory off, or a legacy `manual` memory: the history is `messages`, else the `manual` list,
|
||||
else nothing. Memory is neither read nor written, and a step `memory_id` is ignored.
|
||||
2. Managed memory: a step `messages` is ignored. The memory id is the step's, else the run's, else
|
||||
a legacy id baked into the `auto` object. With no memory id the agent runs stateless.
|
||||
|
||||
Each ignored input and each stateless fallback is written to the job log.
|
||||
|
||||
Memory is stored per (memory id, step id), in `ai_agent_memory` or S3 at
|
||||
`memory/{workspace}/{memory id}/{step}.json`. The chat transcript (`flow_conversation_message`)
|
||||
@@ -73,9 +77,9 @@ overwritten.
|
||||
Compatibility runs one way. New workers read every older shape. The editor rewrites a legacy step
|
||||
only when the author changes it, so a flow nobody edits keeps running on older workers, while a
|
||||
step saved with `window` or a history input needs a worker that knows them. An id an older editor
|
||||
baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as
|
||||
override* or *Use the run's memory id*. In a chat flow it is dropped on save, since the
|
||||
conversation id always took precedence there.
|
||||
baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as memory
|
||||
id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id
|
||||
always took precedence there.
|
||||
|
||||
## Drafts
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface SchemaProperty {
|
||||
pattern?: string
|
||||
default?: any
|
||||
enum?: EnumType
|
||||
/** Display names by stored value, for an enum's options or a one-of's variants. */
|
||||
enumLabels?: Record<string, string>
|
||||
contentEncoding?: 'base64' | 'binary'
|
||||
format?: string
|
||||
items?: {
|
||||
|
||||
@@ -1136,7 +1136,11 @@
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#each oneOf as obj}
|
||||
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
|
||||
<ToggleButton
|
||||
value={obj.title ?? ''}
|
||||
label={extra?.['enumLabels']?.[obj.title ?? ''] ?? obj.title}
|
||||
{item}
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
label?: string
|
||||
/** Replaces the label header, so a setting's own toggle can name the field. */
|
||||
header?: Snippet
|
||||
/** Indent the input under the header's label, for a header that starts with a switch. */
|
||||
indentUnderHeader?: boolean
|
||||
/** Renders after the label: a button to unset the field, a badge. */
|
||||
labelExtra?: Snippet
|
||||
/** Drop the schema's description paragraph, for a form that carries it in a tooltip. */
|
||||
@@ -119,6 +121,7 @@
|
||||
argName = $bindable(),
|
||||
label = undefined,
|
||||
header = undefined,
|
||||
indentUnderHeader = true,
|
||||
labelExtra = undefined,
|
||||
hideDescription = false,
|
||||
subtleControls = false,
|
||||
@@ -863,7 +866,7 @@
|
||||
<!-- A custom header means a setting's toggle owns this field, so the input is
|
||||
indented under the toggle's label: `xs` switch (w-7) plus its ml-2. -->
|
||||
<div
|
||||
class="relative w-full {header ? 'pl-9' : ''}"
|
||||
class="relative w-full {header && indentUnderHeader ? 'pl-9' : ''}"
|
||||
onkeyup={handleKeyUp}
|
||||
transition:slideDynamic|global={{ duration: animateAppear ? 150 : 0 }}
|
||||
>
|
||||
|
||||
@@ -8,17 +8,14 @@
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import type { FlowEditorContext } from './flows/types'
|
||||
import { evalValue } from './flows/utils.svelte'
|
||||
import { memoryPropertyFor } from './flows/flowInfers'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
import type SimpleEditor from './SimpleEditor.svelte'
|
||||
import { getResourceTypes } from './resourceTypesStore'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
AGENT_HISTORY_KEYS,
|
||||
initialVisibleAgentFields
|
||||
} from './flows/agentFormFields'
|
||||
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
|
||||
|
||||
interface Props {
|
||||
schema: Schema | { properties?: Record<string, any>; required?: string[] }
|
||||
@@ -55,6 +52,9 @@
|
||||
* kept whatever the step holds: this form has no add-field control, so hiding one would leave
|
||||
* no way at all to supply it. */
|
||||
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
|
||||
// A legacy memory kind this step still holds stays one of the options, or the one-of field would
|
||||
// turn the test run's memory off.
|
||||
let isAgent = $derived((mod.value as { type?: string })?.type === 'aiagent')
|
||||
|
||||
let visibleKeys = $derived.by(() => {
|
||||
const all = schemaKeys
|
||||
@@ -62,11 +62,12 @@
|
||||
const transforms = (mod.value as { input_transforms?: Record<string, unknown> })
|
||||
?.input_transforms
|
||||
const visible = initialVisibleAgentFields(transforms, schema?.properties)
|
||||
const known = new Set<string>([
|
||||
...AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key),
|
||||
...AGENT_HISTORY_KEYS
|
||||
])
|
||||
return all.filter((key) => !known.has(key) || visible.has(key))
|
||||
const known = new Set<string>(AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key))
|
||||
// Listed in the agent form's order rather than the schema's, so the two read the same.
|
||||
const position = new Map(AGENT_FIELDS.map((f, i) => [f.key, i]))
|
||||
return all
|
||||
.filter((key) => !known.has(key) || visible.has(key))
|
||||
.sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity))
|
||||
})
|
||||
|
||||
let keys: string[] = $state([])
|
||||
@@ -177,7 +178,12 @@
|
||||
(v) => stepsInputArgs?.setStepInputArgs(mod.id, argName, v)
|
||||
}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
oneOf={isAgent && argName === 'memory'
|
||||
? memoryPropertyFor(
|
||||
schema.properties[argName],
|
||||
stepsInputArgs?.getStepInputArgs(mod.id, argName)
|
||||
)?.oneOf
|
||||
: schema.properties[argName].oneOf}
|
||||
required={schema?.required?.includes(argName)}
|
||||
pattern={schema.properties[argName].pattern}
|
||||
bind:editor={editor[argName]}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,8 +3,8 @@ import { AI_AGENT_SCHEMA } from './flowInfers'
|
||||
import {
|
||||
AGENT_FIELD_BY_KEY,
|
||||
AGENT_FIELDS,
|
||||
AGENT_HISTORY_KEYS,
|
||||
memoryIdUnusedNote,
|
||||
historyInputApplies,
|
||||
keepsManagedMemory,
|
||||
agentFieldIsSet,
|
||||
agentStreamingEnabled,
|
||||
initialVisibleAgentFields
|
||||
@@ -63,7 +63,6 @@ describe('initialVisibleAgentFields', () => {
|
||||
max_iterations: { type: 'static', value: 10 }
|
||||
}
|
||||
expect([...initialVisibleAgentFields(legacy, schemaProperties)].sort()).toEqual([
|
||||
'history',
|
||||
'provider',
|
||||
'system_prompt',
|
||||
'tools',
|
||||
@@ -80,8 +79,7 @@ describe('initialVisibleAgentFields', () => {
|
||||
})
|
||||
|
||||
it('covers every schema key, so no field can only be reached through the raw doc', () => {
|
||||
// The history keys are reached through the history row, which edits them as one choice.
|
||||
const registered = new Set<string>([...AGENT_FIELDS.map((f) => f.key), ...AGENT_HISTORY_KEYS])
|
||||
const registered = new Set<string>(AGENT_FIELDS.map((f) => f.key))
|
||||
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -129,15 +127,17 @@ describe('agentStreamingEnabled', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoryIdUnusedNote', () => {
|
||||
// Mirrors the worker's order: offering a memory id that a run would ignore misleads the author.
|
||||
it('offers a memory id only when the policy reads memory', () => {
|
||||
expect(memoryIdUnusedNote(undefined)).toMatch(/off/)
|
||||
expect(memoryIdUnusedNote({ kind: 'off' })).toMatch(/off/)
|
||||
expect(memoryIdUnusedNote({ kind: 'window', context_length: 0 })).toMatch(/off/)
|
||||
expect(memoryIdUnusedNote({ kind: 'auto' })).toMatch(/off/)
|
||||
expect(memoryIdUnusedNote({ kind: 'manual', messages: [] })).toMatch(/fixed list/)
|
||||
expect(memoryIdUnusedNote({ kind: 'window', context_length: 10 })).toBeUndefined()
|
||||
expect(memoryIdUnusedNote({ kind: 'auto', context_length: 4, memory_id: 'x' })).toBeUndefined()
|
||||
describe('historyInputApplies', () => {
|
||||
// Mirrors the worker: offering a step input a run would ignore misleads the author.
|
||||
it('offers a memory id only with managed memory, and messages only without', () => {
|
||||
expect(keepsManagedMemory(undefined)).toBe(false)
|
||||
expect(keepsManagedMemory({ kind: 'window', context_length: 0 })).toBe(false)
|
||||
expect(keepsManagedMemory({ kind: 'manual', messages: [] })).toBe(false)
|
||||
expect(keepsManagedMemory({ kind: 'auto', context_length: 4, memory_id: 'x' })).toBe(true)
|
||||
expect(historyInputApplies('memory_id', true)).toBe(true)
|
||||
expect(historyInputApplies('messages', true)).toBe(false)
|
||||
expect(historyInputApplies('memory_id', false)).toBe(false)
|
||||
expect(historyInputApplies('messages', false)).toBe(true)
|
||||
expect(historyInputApplies('messages', undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,46 +25,39 @@ export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [
|
||||
* It lives in the registry so the groups keep a single ordering. */
|
||||
export const AGENT_TOOLS_ROW = 'tools'
|
||||
|
||||
/** The step's history row, which edits `memory_id` and `messages` as one choice between them. */
|
||||
export const AGENT_HISTORY_ROW = 'history'
|
||||
|
||||
/** A step's own history inputs. Never seeded with a placeholder: a run reads a present key as the
|
||||
* step's choice, so only the history row writes them, and only one at a time. */
|
||||
* step's choice, so only the author adds them. */
|
||||
export const AGENT_HISTORY_KEYS = ['memory_id', 'messages'] as const
|
||||
export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number]
|
||||
|
||||
/** What a new agent remembers. An agent with no memory written still runs without, as it always has. */
|
||||
/** What turning managed memory on writes. */
|
||||
export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 }
|
||||
|
||||
/** Whether a memory setting keeps nothing, mirroring the worker: absent, `off`, or a message count of
|
||||
* 0 for `window` and its older spelling `auto`. */
|
||||
export function memoryPolicyIsOff(memory: any): boolean {
|
||||
if (memory == undefined || memory.kind === 'off') return true
|
||||
if (memory.kind === 'window' || memory.kind === 'auto') return !memory.context_length
|
||||
return false
|
||||
/** The docs section on how an agent's memory is named and kept. */
|
||||
export const AGENT_MEMORY_DOCS_URL =
|
||||
'https://www.windmill.dev/docs/core_concepts/ai_agents#memory-auto--manual'
|
||||
|
||||
/** Whether Windmill stores and replays the agent's conversation, mirroring the worker: `window`, or
|
||||
* its older spelling `auto`, with a message count above 0. A legacy `manual` list is not managed. */
|
||||
export function keepsManagedMemory(memory: any): boolean {
|
||||
return (memory?.kind === 'window' || memory?.kind === 'auto') && Boolean(memory.context_length)
|
||||
}
|
||||
|
||||
/** Why a step's history row offers no memory id, when it offers none: the agent keeps no memory, or
|
||||
* its memory setting supplies a fixed list of messages, which the worker sends before it would
|
||||
* read any memory. */
|
||||
export function memoryIdUnusedNote(memory: any): string | undefined {
|
||||
if (memory?.kind === 'manual') {
|
||||
return 'This agent sends a fixed list of messages set in its memory, so no memory id applies.'
|
||||
}
|
||||
if (memoryPolicyIsOff(memory)) {
|
||||
return "This agent's memory is off, so no memory id applies. Turn memory on in the agent to change this."
|
||||
}
|
||||
return undefined
|
||||
/** Whether a run reads this step input, mirroring the worker: managed memory reads only a memory id,
|
||||
* memory that is off only messages. A setting the form cannot read yet leaves both open. */
|
||||
export function historyInputApplies(
|
||||
key: AgentHistoryKey,
|
||||
managedMemory: boolean | undefined
|
||||
): boolean {
|
||||
if (managedMemory === undefined) return true
|
||||
return (key === 'memory_id') === managedMemory
|
||||
}
|
||||
|
||||
/** A memory setting in words, for a linked agent's summary. */
|
||||
export function describeMemoryPolicy(memory: any): string {
|
||||
if (memory?.kind === 'manual') return 'Provided messages'
|
||||
if (memoryPolicyIsOff(memory)) return 'Off'
|
||||
if (memory.kind === 'window' || memory.kind === 'auto') {
|
||||
return `Keep last ${memory.context_length} messages`
|
||||
}
|
||||
return String(memory.kind ?? 'configured')
|
||||
if (keepsManagedMemory(memory)) return `Last ${memory.context_length} messages`
|
||||
if (memory?.kind === 'manual') return 'Off, sends a fixed list of messages'
|
||||
return 'Off'
|
||||
}
|
||||
|
||||
export interface AgentFieldSpec {
|
||||
@@ -116,6 +109,14 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
tooltip: 'The most tokens the model may produce in its answer.',
|
||||
defaultHint: 'Default: the provider decides'
|
||||
},
|
||||
{
|
||||
key: 'user_message',
|
||||
group: 'messages',
|
||||
label: 'User message',
|
||||
tooltip:
|
||||
"The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.",
|
||||
core: true
|
||||
},
|
||||
{
|
||||
key: 'system_prompt',
|
||||
group: 'messages',
|
||||
@@ -126,29 +127,30 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
{
|
||||
key: 'memory',
|
||||
group: 'messages',
|
||||
label: 'Memory',
|
||||
tooltip: 'How much of its history the agent sends with each request.',
|
||||
label: 'Managed memory',
|
||||
tooltip: 'Windmill stores the conversation and sends its last messages with each request.',
|
||||
implicit: { kind: 'off' },
|
||||
defaultHint: 'Default: off',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: AGENT_HISTORY_ROW,
|
||||
key: 'memory_id',
|
||||
group: 'messages',
|
||||
label: 'History',
|
||||
label: 'Memory id',
|
||||
tooltip:
|
||||
'Which memory this step reads and writes, or the messages this flow provides in its place. Sent between the system message and the user message.',
|
||||
core: true,
|
||||
virtual: true,
|
||||
'Conversation history id: runs with the same id share their history. Inherited uses the chat conversation id in chat mode, or the memory_id passed when the flow is run. Without either, each run starts fresh. Custom sets the id on the step: a fixed id shares one history across all runs, an expression keeps one history per value.',
|
||||
implicit: '',
|
||||
defaultHint: 'Default: inherited from the run',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: 'user_message',
|
||||
key: 'messages',
|
||||
group: 'messages',
|
||||
label: 'User message',
|
||||
tooltip:
|
||||
"The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.",
|
||||
core: true
|
||||
label: 'Previous messages',
|
||||
tooltip: 'History the flow supplies, sent between the system message and the user message.',
|
||||
implicit: [],
|
||||
defaultHint: 'Default: none',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: 'user_attachments',
|
||||
@@ -261,10 +263,6 @@ export function agentFieldAppliesTo(
|
||||
spec: AgentFieldSpec,
|
||||
schemaProperties: Record<string, any> | undefined
|
||||
): boolean {
|
||||
// The history inputs belong to the step, so they stay on a linked step's reduced schema.
|
||||
if (spec.key === AGENT_HISTORY_ROW) {
|
||||
return Boolean(schemaProperties && 'memory_id' in schemaProperties)
|
||||
}
|
||||
// A virtual row has no schema key to look for, so it keys off the brain being editable here.
|
||||
if (spec.virtual) return Boolean(schemaProperties && 'provider' in schemaProperties)
|
||||
return Boolean(schemaProperties && spec.key in schemaProperties)
|
||||
@@ -283,9 +281,5 @@ export function initialVisibleAgentFields(
|
||||
if (!agentFieldAppliesTo(spec, schemaProperties)) continue
|
||||
if (agentFieldIsSet(spec, args?.[spec.key])) visible.add(spec.key)
|
||||
}
|
||||
// The history row stands for these keys; a form listing a run's inputs shows whichever is set.
|
||||
for (const key of AGENT_HISTORY_KEYS) {
|
||||
if (args?.[key] && schemaProperties && key in schemaProperties) visible.add(key)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('summarizeAgentBrain', () => {
|
||||
output_schema: { type: 'object' } as any
|
||||
})
|
||||
expect(rows).toEqual([
|
||||
{ label: 'Memory', value: 'Keep last 20 messages' },
|
||||
{ label: 'Managed memory', value: 'Last 20 messages' },
|
||||
{ label: 'Output schema', value: 'configured' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AGENT_HISTORY_KEYS, DEFAULT_AGENT_MEMORY } from './agentFormFields'
|
||||
import { AGENT_HISTORY_KEYS } from './agentFormFields'
|
||||
import type { AiAgent, FlowModule, FlowModuleValue, InputTransform } from '$lib/gen'
|
||||
import { loadStoredConfig } from '../aiProviderStorage'
|
||||
import { AI_AGENT_SCHEMA } from './flowInfers'
|
||||
@@ -110,7 +110,6 @@ export function createAiAgentTool(id: string): AiAgentTool {
|
||||
value: loadStoredConfig() ?? { kind: 'openai', resource: '', model: '' }
|
||||
},
|
||||
output_type: { type: 'static', value: 'text' },
|
||||
memory: { type: 'static', value: structuredClone(DEFAULT_AGENT_MEMORY) },
|
||||
user_message: { type: 'ai' }
|
||||
}
|
||||
for (const key of Object.keys(AI_AGENT_SCHEMA.properties ?? {})) {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import FieldHeader from '$lib/components/FieldHeader.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import type { AgentHistoryKey } from '../agentFormFields'
|
||||
|
||||
type Source = 'run' | 'here' | 'messages'
|
||||
|
||||
interface Props {
|
||||
/** The step's input transforms. `memory_id` and `messages` are written only by this row. */
|
||||
args: Record<string, any>
|
||||
label: string
|
||||
tooltip?: string
|
||||
chatInputEnabled?: boolean
|
||||
/** Why no memory id applies to this step, when the agent reads no memory. */
|
||||
memoryUnusedNote?: string
|
||||
/** What providing messages starts from: the list a legacy `manual` memory already sends, which the
|
||||
* step's own messages replace at runtime. */
|
||||
seedMessages?: unknown[]
|
||||
/** The step's editor for one history input, with the error to show under it. */
|
||||
field: Snippet<[AgentHistoryKey, string | undefined]>
|
||||
/** Called for each key this row removes, so the form forgets its validity. */
|
||||
onRemoveKey?: (key: AgentHistoryKey) => void
|
||||
}
|
||||
|
||||
let {
|
||||
args = $bindable(),
|
||||
label,
|
||||
tooltip = undefined,
|
||||
chatInputEnabled = false,
|
||||
memoryUnusedNote = undefined,
|
||||
seedMessages = undefined,
|
||||
field,
|
||||
onRemoveKey = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// Neither key gets a placeholder, so whichever one is present is the chosen source.
|
||||
let source: Source = $derived(args?.messages ? 'messages' : args?.memory_id ? 'here' : 'run')
|
||||
let memoryId = $derived(args?.memory_id)
|
||||
let fixedMemoryId = $derived(memoryId?.type === 'static')
|
||||
let emptyMemoryId = $derived(fixedMemoryId && !String(memoryId?.value ?? '').trim())
|
||||
|
||||
function remove(key: AgentHistoryKey) {
|
||||
if (args && key in args) {
|
||||
delete args[key]
|
||||
onRemoveKey?.(key)
|
||||
}
|
||||
}
|
||||
|
||||
function selectSource(next: Source) {
|
||||
if (next === source) return
|
||||
remove('memory_id')
|
||||
remove('messages')
|
||||
if (next === 'here') args.memory_id = { type: 'static', value: '' }
|
||||
if (next === 'messages') {
|
||||
args.messages = {
|
||||
type: 'static',
|
||||
value: structuredClone($state.snapshot(seedMessages ?? []))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex min-h-7 items-end">
|
||||
<FieldHeader {label} simpleTooltip={tooltip} displayType={false} />
|
||||
</div>
|
||||
{#if memoryUnusedNote}
|
||||
<!-- The step's own messages replace whatever the memory setting would send, so the note stops
|
||||
applying once they are provided. -->
|
||||
{#if source !== 'messages'}
|
||||
<p class="text-xs text-secondary">{memoryUnusedNote}</p>
|
||||
{/if}
|
||||
{#if source === 'here'}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge color="yellow" small>Ignored</Badge>
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={() => remove('memory_id')}>
|
||||
Clear memory id
|
||||
</Button>
|
||||
</div>
|
||||
{@render field('memory_id', undefined)}
|
||||
{/if}
|
||||
{#if source === 'messages'}
|
||||
{@render field('messages', undefined)}
|
||||
<div>
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={() => remove('messages')}>
|
||||
Remove messages
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button variant="default" unifiedSize="sm" onclick={() => selectSource('messages')}>
|
||||
Provide messages
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<ToggleButtonGroup selected={source} onSelected={(next) => selectSource(next)}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="run" label="From the run" {item} />
|
||||
<ToggleButton value="here" label="Set here" {item} />
|
||||
<ToggleButton value="messages" label="Provided messages" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if source === 'run'}
|
||||
{#if chatInputEnabled}
|
||||
<div><Badge color="gray">Chat conversation</Badge></div>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">
|
||||
Passed by the caller as <code>memory_id</code>, stateless otherwise.
|
||||
</p>
|
||||
{/if}
|
||||
{:else if source === 'here'}
|
||||
{@render field(
|
||||
'memory_id',
|
||||
emptyMemoryId ? 'Enter a memory id, or choose From the run.' : undefined
|
||||
)}
|
||||
<p class="text-2xs text-hint">
|
||||
{#if emptyMemoryId}
|
||||
A fixed id such as <code>support-triage</code> keeps one memory shared by every run, an
|
||||
expression such as <code>flow_input.customer_id</code> one memory per key.
|
||||
{:else if fixedMemoryId}
|
||||
Every run of this step shares this memory. Overrides the memory id passed by the caller.
|
||||
{:else}
|
||||
Overrides the memory id passed by the caller.
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
{@render field('messages', undefined)}
|
||||
<p class="text-2xs text-hint">
|
||||
Messages sent before the user message, supplied by this flow instead of read from memory.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,153 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import FieldHeader from '$lib/components/FieldHeader.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { DEFAULT_AGENT_MEMORY, memoryPolicyIsOff } from '../agentFormFields'
|
||||
|
||||
interface Props {
|
||||
/** The agent's input transforms. `memory` is written whole; converting a legacy setting also
|
||||
* writes the history input it moves into. */
|
||||
args: Record<string, any>
|
||||
label: string
|
||||
tooltip?: string
|
||||
labelExtra?: Snippet
|
||||
chatInputEnabled?: boolean
|
||||
/** Whether the step's history row is on this form, so a legacy memory id or messages can move
|
||||
* into it. A saved agent has none: its history belongs to each step linking it. */
|
||||
historyOnStep?: boolean
|
||||
s3StorageConfigured?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
args = $bindable(),
|
||||
label,
|
||||
tooltip = undefined,
|
||||
labelExtra = undefined,
|
||||
chatInputEnabled = false,
|
||||
historyOnStep = false,
|
||||
s3StorageConfigured = true
|
||||
}: Props = $props()
|
||||
|
||||
const POLICY_ITEMS = [
|
||||
{ label: 'Keep last messages', value: 'window' },
|
||||
{ label: 'Off', value: 'off' }
|
||||
]
|
||||
|
||||
let memory = $derived(args?.memory?.value as Record<string, any> | null | undefined)
|
||||
// `auto` and `manual` are what older editors wrote. They are shown as they are and only rewritten
|
||||
// once the author changes the setting, so an untouched step still runs on an older worker.
|
||||
let kind = $derived(
|
||||
memory?.kind === 'manual' ? 'manual' : memoryPolicyIsOff(memory) ? 'off' : 'window'
|
||||
)
|
||||
// A chat run always carries the conversation's memory id, so there a baked id was never read.
|
||||
let legacyMemoryId = $derived(
|
||||
kind === 'window' && memory?.kind === 'auto' && memory.memory_id && !chatInputEnabled
|
||||
? String(memory.memory_id)
|
||||
: undefined
|
||||
)
|
||||
|
||||
function write(value: Record<string, any>) {
|
||||
args.memory = { type: 'static', value }
|
||||
}
|
||||
|
||||
function setKind(next: string) {
|
||||
if (next === kind) return
|
||||
write(next === 'window' ? structuredClone(DEFAULT_AGENT_MEMORY) : { kind: 'off' })
|
||||
}
|
||||
|
||||
function setContextLength(next: string | number | undefined) {
|
||||
const contextLength = Math.floor(Number(next))
|
||||
// An emptied box would otherwise turn memory off and hide the box being typed into.
|
||||
if (!Number.isFinite(contextLength) || contextLength < 1) return
|
||||
// A baked id survives a new count: it goes only through the choice offered for it below.
|
||||
write(
|
||||
memory?.memory_id
|
||||
? { ...memory, context_length: contextLength }
|
||||
: { kind: 'window', context_length: contextLength }
|
||||
)
|
||||
}
|
||||
|
||||
function convertLegacyMemoryId(keepAsOverride: boolean) {
|
||||
if (keepAsOverride && legacyMemoryId) {
|
||||
args.memory_id = { type: 'static', value: legacyMemoryId }
|
||||
}
|
||||
write({ kind: 'window', context_length: memory?.context_length })
|
||||
}
|
||||
|
||||
function moveMessagesToHistory() {
|
||||
args.messages = { type: 'static', value: memory?.messages ?? [] }
|
||||
write({ kind: 'off' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<div class="flex min-h-7 items-end">
|
||||
<FieldHeader {label} simpleTooltip={tooltip} displayType={false} />
|
||||
{@render labelExtra?.()}
|
||||
</div>
|
||||
{#if kind === 'manual'}
|
||||
<div class="flex flex-col gap-2 rounded-md border px-3 py-2">
|
||||
<p class="text-xs text-secondary">
|
||||
This agent sends a fixed list of messages, set in its memory by an earlier version of the
|
||||
editor.
|
||||
</p>
|
||||
{#if historyOnStep}
|
||||
<div>
|
||||
<Button variant="default" unifiedSize="sm" onclick={moveMessagesToHistory}>
|
||||
Move to history
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-52">
|
||||
<Select items={POLICY_ITEMS} bind:value={() => kind, (next) => next && setKind(next)} />
|
||||
</div>
|
||||
{#if kind === 'window'}
|
||||
<div class="w-20">
|
||||
<TextInput
|
||||
inputProps={{ type: 'number', min: 1, 'aria-label': 'Messages to keep' }}
|
||||
bind:value={() => memory?.context_length, setContextLength}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-secondary">messages</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if kind === 'window'}
|
||||
<p class="text-2xs text-hint">
|
||||
History is kept per memory id: the chat conversation, an app chat session, a memory id
|
||||
passed when the run starts, or one set on the step. Without a memory id the agent runs
|
||||
stateless.
|
||||
</p>
|
||||
{#if !s3StorageConfigured}
|
||||
<p class="text-2xs text-hint">
|
||||
Without S3 storage on the workspace, memory is kept in the database, up to 100KB per
|
||||
memory.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if legacyMemoryId}
|
||||
<div class="flex flex-col gap-2 rounded-md border px-3 py-2">
|
||||
<p class="text-xs text-secondary">
|
||||
{historyOnStep
|
||||
? 'Fixed memory id generated when this flow was saved.'
|
||||
: 'Fixed memory id saved with this agent.'}
|
||||
Every run shares it unless the caller passes one.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if historyOnStep}
|
||||
<Button variant="default" unifiedSize="sm" onclick={() => convertLegacyMemoryId(true)}>
|
||||
Keep as override
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="default" unifiedSize="sm" onclick={() => convertLegacyMemoryId(false)}>
|
||||
Use the run's memory id
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { keepsManagedMemory } from '../agentFormFields'
|
||||
|
||||
interface Props {
|
||||
/** The agent's input transforms. Converting a legacy setting writes `memory` and the step input
|
||||
* it moves into. */
|
||||
args: Record<string, any>
|
||||
chatInputEnabled?: boolean
|
||||
/** Whether the step's own memory id and messages are on this form. A saved agent has neither:
|
||||
* they belong to each step linking it. */
|
||||
historyOnStep?: boolean
|
||||
s3StorageConfigured?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
args = $bindable(),
|
||||
chatInputEnabled = false,
|
||||
historyOnStep = false,
|
||||
s3StorageConfigured = true
|
||||
}: Props = $props()
|
||||
|
||||
let memory = $derived(
|
||||
args?.memory?.type === 'static'
|
||||
? (args.memory.value as Record<string, any> | null | undefined)
|
||||
: undefined
|
||||
)
|
||||
let on = $derived(keepsManagedMemory(memory))
|
||||
// `auto` and `manual` are what older editors wrote. They stay as they are until the author
|
||||
// converts them, so an untouched step still runs on an older worker.
|
||||
let legacyMessages = $derived(
|
||||
memory?.kind === 'manual' ? ((memory.messages ?? []) as unknown[]) : undefined
|
||||
)
|
||||
// A chat run always carries the conversation's memory id, so there a baked id was never read.
|
||||
let legacyMemoryId = $derived(
|
||||
on && memory?.kind === 'auto' && memory.memory_id && !chatInputEnabled
|
||||
? String(memory.memory_id)
|
||||
: undefined
|
||||
)
|
||||
|
||||
// An `auto` setting whose saved id is never read runs exactly like the current setting for its
|
||||
// state, so switching to that setting is the only choice.
|
||||
let legacyEquivalent = $derived(memory?.kind === 'auto' && !legacyMemoryId)
|
||||
|
||||
function switchToEquivalent() {
|
||||
args.memory = {
|
||||
type: 'static',
|
||||
value: on ? { kind: 'window', context_length: memory?.context_length } : { kind: 'off' }
|
||||
}
|
||||
}
|
||||
|
||||
function convertLegacyMemoryId(keepAsMemoryId: boolean) {
|
||||
if (keepAsMemoryId && legacyMemoryId) {
|
||||
args.memory_id = { type: 'static', value: legacyMemoryId }
|
||||
}
|
||||
args.memory = {
|
||||
type: 'static',
|
||||
value: { kind: 'window', context_length: memory?.context_length }
|
||||
}
|
||||
}
|
||||
|
||||
function moveMessagesToStep() {
|
||||
args.messages = { type: 'static', value: $state.snapshot(legacyMessages) ?? [] }
|
||||
args.memory = { type: 'static', value: { kind: 'off' } }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if on && !s3StorageConfigured}
|
||||
<p class="mt-1 text-2xs text-hint">
|
||||
Without S3 storage on the workspace, memory is kept in the database, up to 100KB per memory.
|
||||
</p>
|
||||
{/if}
|
||||
{#if legacyMessages}
|
||||
<Alert type="info" title="Older memory setting" class="mt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
An earlier version of the editor saved a fixed list of messages here, which this agent still
|
||||
sends.
|
||||
</span>
|
||||
{#if historyOnStep}
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="bg-surface"
|
||||
onclick={moveMessagesToStep}
|
||||
>
|
||||
Move to previous messages
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if legacyMemoryId}
|
||||
<Alert type="info" title="Older memory setting" class="mt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{historyOnStep
|
||||
? 'Fixed memory id generated when this flow was saved.'
|
||||
: 'Fixed memory id saved with this agent.'}
|
||||
Every run shares it unless the caller passes one.
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
{#if historyOnStep}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="bg-surface"
|
||||
onclick={() => convertLegacyMemoryId(true)}
|
||||
>
|
||||
Keep as memory id
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="bg-surface"
|
||||
onclick={() => convertLegacyMemoryId(false)}
|
||||
>
|
||||
Use the run's memory id
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if legacyEquivalent}
|
||||
<Alert type="info" title="Older memory setting" class="mt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
An earlier version of the editor saved this setting. It works the same as {on
|
||||
? 'On'
|
||||
: 'Off'}.
|
||||
</span>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="bg-surface"
|
||||
onclick={switchToEquivalent}
|
||||
>
|
||||
Switch to {on ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
@@ -70,7 +70,7 @@
|
||||
// is the wrong way in.
|
||||
fromAgentEditor?: boolean
|
||||
chatInputEnabled?: boolean
|
||||
// The linked agent's memory once its config has loaded, for the step's history row.
|
||||
// The linked agent's memory once its config has loaded, for the step's history inputs.
|
||||
linkedMemory?: { memory: unknown } | undefined
|
||||
} = $props()
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
if (chatInputEnabled || value?.kind !== 'auto' || !value.memory_id || !value.context_length) {
|
||||
return undefined
|
||||
}
|
||||
return "This step still uses a fixed memory id from an earlier version. In Memory, choose Keep as override or Use the run's memory id, then save it as an agent."
|
||||
return "This step still uses a fixed memory id from an earlier version. In Managed memory, choose Keep as memory id or Use the run's memory id, then save it as an agent."
|
||||
})
|
||||
|
||||
let providerSaveError = $derived.by(() => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { type InputTransform } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { getContext, untrack, type Snippet } from 'svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { Button } from '$lib/components/common'
|
||||
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
|
||||
@@ -33,24 +33,31 @@
|
||||
import type VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
import FieldHeader from '$lib/components/FieldHeader.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import { Plus, X } from 'lucide-svelte'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import type { AgentTool } from '../agentToolUtils'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
AGENT_HISTORY_ROW,
|
||||
AGENT_FIELD_BY_KEY,
|
||||
AGENT_HISTORY_KEYS,
|
||||
AGENT_MEMORY_DOCS_URL,
|
||||
AGENT_TOOLS_ROW,
|
||||
memoryIdUnusedNote,
|
||||
AGENT_FIELD_GROUPS,
|
||||
agentFieldAppliesTo,
|
||||
historyInputApplies,
|
||||
initialVisibleAgentFields,
|
||||
keepsManagedMemory,
|
||||
type AgentFieldGroup,
|
||||
type AgentFieldSpec
|
||||
type AgentFieldSpec,
|
||||
type AgentHistoryKey
|
||||
} from '../agentFormFields'
|
||||
import AgentToolRoster from './AgentToolRoster.svelte'
|
||||
import AgentMemoryInput from './AgentMemoryInput.svelte'
|
||||
import AgentHistoryInput from './AgentHistoryInput.svelte'
|
||||
import AgentMemoryNotes from './AgentMemoryNotes.svelte'
|
||||
import { memoryPropertyFor } from '../flowInfers'
|
||||
|
||||
interface Props {
|
||||
schema: Schema | { properties?: Record<string, any> }
|
||||
@@ -94,8 +101,8 @@
|
||||
onDeleteTool?: (toolId: string) => void
|
||||
/** Where the tool picker's popover belongs, for a surface that is not the flow editor. */
|
||||
toolPickerPortal?: string
|
||||
/** A linked agent's memory, once its config has loaded: whether it keeps any decides what the
|
||||
* step's history row offers. */
|
||||
/** A linked agent's memory, once its config has loaded: whether it keeps managed memory decides
|
||||
* which history inputs the step offers. */
|
||||
linkedMemory?: { memory: unknown } | undefined
|
||||
}
|
||||
|
||||
@@ -162,30 +169,32 @@
|
||||
|
||||
let schemaProperties = $derived((schema?.properties ?? {}) as Record<string, any>)
|
||||
|
||||
// The brain edited here, or the linked agent's. An expression, or a linked agent still loading,
|
||||
// reads as keeping memory, so the history row never offers less than a run may use.
|
||||
let memoryUnusedNote = $derived.by(() => {
|
||||
// Whether the brain edited here, or the linked agent's, keeps managed memory. Unknown for an
|
||||
// expression or a linked agent still loading, which leaves both history inputs open.
|
||||
let managedMemory = $derived.by((): boolean | undefined => {
|
||||
if ('memory' in schemaProperties) {
|
||||
const transform = args?.memory
|
||||
return transform == undefined || transform.type === 'static'
|
||||
? memoryIdUnusedNote(transform?.value)
|
||||
? keepsManagedMemory(transform?.value)
|
||||
: undefined
|
||||
}
|
||||
return linkedMemory ? memoryIdUnusedNote(linkedMemory.memory) : undefined
|
||||
return linkedMemory ? keepsManagedMemory(linkedMemory.memory) : undefined
|
||||
})
|
||||
|
||||
// The messages a legacy `manual` memory sends, so providing messages on the step starts from them
|
||||
// rather than from an empty list that would silently drop them.
|
||||
let manualMemoryMessages = $derived.by(() => {
|
||||
const memory: any =
|
||||
'memory' in schemaProperties
|
||||
? args?.memory?.type === 'static'
|
||||
? args.memory.value
|
||||
: undefined
|
||||
: linkedMemory?.memory
|
||||
return memory?.kind === 'manual' ? (memory.messages as unknown[] | undefined) : undefined
|
||||
// The one-of field rewrites a value that matches none of its options, so a legacy kind the step
|
||||
// still holds is offered alongside the current ones.
|
||||
let memoryFieldSchema = $derived.by(() => {
|
||||
const property = schemaProperties.memory
|
||||
const value = args?.memory?.type === 'static' ? args.memory.value : undefined
|
||||
const withLegacy = memoryPropertyFor(property, value)
|
||||
if (withLegacy === property) return schema
|
||||
return { ...schema, properties: { ...schemaProperties, memory: withLegacy } }
|
||||
})
|
||||
|
||||
function isHistoryKey(key: string): key is AgentHistoryKey {
|
||||
return (AGENT_HISTORY_KEYS as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
let scopedFields = $derived(
|
||||
AGENT_FIELDS.filter(
|
||||
(spec) =>
|
||||
@@ -193,6 +202,37 @@
|
||||
)
|
||||
)
|
||||
|
||||
// Offered whenever the agent may keep managed memory: on, or an expression the form cannot read.
|
||||
// Unset, the memory id the run was started with applies, so the step's own id sits behind a
|
||||
// choice and the key exists only once Custom is picked.
|
||||
let memoryIsExpression = $derived(
|
||||
'memory' in schemaProperties &&
|
||||
(args?.memory?.type === 'javascript' || args?.memory?.type === 'ai')
|
||||
)
|
||||
let memoryIdOffered = $derived(
|
||||
(managedMemory === true || memoryIsExpression) &&
|
||||
scopedFields.some((spec) => spec.key === 'memory_id')
|
||||
)
|
||||
|
||||
// A history input's row follows its key: the remembered `visible` set can outlive a key that a
|
||||
// save, an undo or the AI chat removed, and a row with no value renders no field.
|
||||
function isShown(key: string): boolean {
|
||||
if (isHistoryKey(key)) {
|
||||
return args?.[key] != undefined || (key === 'memory_id' && memoryIdOffered)
|
||||
}
|
||||
return visible.has(key)
|
||||
}
|
||||
|
||||
function setCustomMemoryId(on: boolean) {
|
||||
if (!args || on === (args.memory_id != undefined)) return
|
||||
if (on) {
|
||||
args.memory_id = { type: 'static', value: '' }
|
||||
} else {
|
||||
delete args.memory_id
|
||||
delete inputCheck.memory_id
|
||||
}
|
||||
}
|
||||
|
||||
let outputType = $derived.by(() => {
|
||||
const transform = args?.['output_type']
|
||||
return transform && transform.type === 'static' ? transform.value : undefined
|
||||
@@ -239,14 +279,18 @@
|
||||
|
||||
function rowsIn(group: AgentFieldGroup): AgentFieldSpec[] {
|
||||
return scopedFields.filter(
|
||||
(spec) => spec.group === group && visible.has(spec.key) && !(imageOutput && spec.textOnly)
|
||||
(spec) => spec.group === group && isShown(spec.key) && !(imageOutput && spec.textOnly)
|
||||
)
|
||||
}
|
||||
|
||||
function addableIn(): AgentFieldSpec[] {
|
||||
return scopedFields.filter(
|
||||
(spec) =>
|
||||
!spec.core && !spec.virtual && !visible.has(spec.key) && !(imageOutput && spec.textOnly)
|
||||
!spec.core &&
|
||||
!spec.virtual &&
|
||||
!isShown(spec.key) &&
|
||||
!(imageOutput && spec.textOnly) &&
|
||||
!(isHistoryKey(spec.key) && !historyInputApplies(spec.key, managedMemory))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,10 +306,14 @@
|
||||
function removeField(spec: AgentFieldSpec) {
|
||||
visible.delete(spec.key)
|
||||
if (args) {
|
||||
// Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind.
|
||||
// Never `delete args[key]`: the key returns on the next load, and the CLI linter requires
|
||||
// `user_message` to be present.
|
||||
args[spec.key] = { type: 'static', value: undefined }
|
||||
if (isHistoryKey(spec.key)) {
|
||||
delete args[spec.key]
|
||||
} else {
|
||||
// Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind.
|
||||
// Never `delete args[key]`: the key returns on the next load, and the CLI linter requires
|
||||
// `user_message` to be present.
|
||||
args[spec.key] = { type: 'static', value: undefined }
|
||||
}
|
||||
}
|
||||
// InputTransformSchemaForm leaks these on unmount, which would pin `isValid` false forever
|
||||
// once hiding a row is routine.
|
||||
@@ -299,24 +347,52 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet memoryIdHeader()}
|
||||
<div class="flex flex-col gap-1">
|
||||
<FieldHeader
|
||||
label={AGENT_FIELD_BY_KEY.memory_id.label}
|
||||
simpleTooltip={AGENT_FIELD_BY_KEY.memory_id.tooltip}
|
||||
displayType={false}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
selected={args?.memory_id == undefined ? 'inherited' : 'custom'}
|
||||
onSelected={(next) => setCustomMemoryId(next === 'custom')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="inherited" label="Inherited" {item} />
|
||||
<ToggleButton value="custom" label="Custom" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet transformField(
|
||||
key: string,
|
||||
label: string,
|
||||
tooltip: string | undefined,
|
||||
removable: AgentFieldSpec | undefined,
|
||||
error: string | undefined = undefined
|
||||
header: Snippet | undefined = undefined,
|
||||
collapsed: boolean = false
|
||||
)}
|
||||
<InputTransformForm
|
||||
{previousModuleId}
|
||||
bind:arg={args[key]}
|
||||
bind:schema
|
||||
bind:schema={
|
||||
() => (key === 'memory' ? memoryFieldSchema : schema),
|
||||
(value) => {
|
||||
if (key !== 'memory') schema = value
|
||||
}
|
||||
}
|
||||
argName={key}
|
||||
{label}
|
||||
headerTooltip={tooltip}
|
||||
hideDescription
|
||||
subtleControls
|
||||
{header}
|
||||
indentUnderHeader={false}
|
||||
{collapsed}
|
||||
animateAppear={header != undefined}
|
||||
argExtra={schemaProperties[key] ?? {}}
|
||||
{error}
|
||||
bind:inputCheck={() => inputCheck[key] ?? false, (value) => (inputCheck[key] = value)}
|
||||
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
|
||||
{variableEditor}
|
||||
@@ -421,41 +497,42 @@
|
||||
`args`, and a read-only viewer's edit is rejected by the server. Dimmed
|
||||
with it, so a field that ignores a click looks like it meant to. -->
|
||||
<div class="w-full {readOnly ? 'opacity-60' : ''}" inert={readOnly}>
|
||||
{#if spec.key === AGENT_HISTORY_ROW}
|
||||
<AgentHistoryInput
|
||||
{#if spec.key === 'memory'}
|
||||
{@render transformField(spec.key, spec.label, spec.tooltip, spec)}
|
||||
<AgentMemoryNotes
|
||||
bind:args
|
||||
label={spec.label}
|
||||
tooltip={spec.tooltip}
|
||||
{chatInputEnabled}
|
||||
{memoryUnusedNote}
|
||||
seedMessages={manualMemoryMessages}
|
||||
onRemoveKey={(key) => delete inputCheck[key]}
|
||||
>
|
||||
{#snippet field(key, error)}
|
||||
{@render transformField(
|
||||
key,
|
||||
key === 'memory_id' ? 'Memory id' : 'Messages',
|
||||
undefined,
|
||||
undefined,
|
||||
error
|
||||
)}
|
||||
{/snippet}
|
||||
</AgentHistoryInput>
|
||||
{:else if spec.key === 'memory' && args?.memory?.type !== 'javascript' && args?.memory?.type !== 'ai'}
|
||||
<AgentMemoryInput
|
||||
bind:args
|
||||
label={spec.label}
|
||||
tooltip={spec.tooltip}
|
||||
{chatInputEnabled}
|
||||
historyOnStep={scopedFields.some((f) => f.key === AGENT_HISTORY_ROW)}
|
||||
historyOnStep={scopedFields.some((f) => f.key === 'messages')}
|
||||
s3StorageConfigured={s3Storage.current}
|
||||
>
|
||||
{#snippet labelExtra()}
|
||||
{@render unsetButton(spec)}
|
||||
{/snippet}
|
||||
</AgentMemoryInput>
|
||||
/>
|
||||
{:else if spec.key === 'memory_id' && memoryIdOffered}
|
||||
{@render transformField(
|
||||
spec.key,
|
||||
spec.label,
|
||||
spec.tooltip,
|
||||
undefined,
|
||||
memoryIdHeader,
|
||||
args?.memory_id == undefined
|
||||
)}
|
||||
{#if args?.memory_id == undefined}
|
||||
<p class="mt-1 text-xs text-secondary">
|
||||
Uses the chat conversation id or the <code>memory_id</code> passed to the
|
||||
run.
|
||||
<a
|
||||
href={AGENT_MEMORY_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline">Learn more</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{@render transformField(spec.key, spec.label, spec.tooltip, spec)}
|
||||
{#if isHistoryKey(spec.key) && !historyInputApplies(spec.key, managedMemory)}
|
||||
<p class="mt-1 text-2xs text-hint">
|
||||
Ignored while managed memory is {managedMemory ? 'on' : 'off'}.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -594,14 +594,14 @@
|
||||
}
|
||||
]
|
||||
sendUserToast(
|
||||
'Chat mode enabled. AI agent created with the user message as its input.',
|
||||
'Chat mode enabled. AI agent created with the user message as its input and managed memory on.',
|
||||
false
|
||||
)
|
||||
} else if (aiAgentModules.length === 1) {
|
||||
// Exactly one AI agent exists: fill in defaults only for inputs the
|
||||
// user hasn't configured, so re-enabling chat mode on an already
|
||||
// configured agent doesn't clobber a custom user_message expression
|
||||
// or memory, which chat mode leaves to the agent.
|
||||
// or a deliberate memory choice (e.g. off).
|
||||
const aiAgent = aiAgentModules[0]
|
||||
const value = aiAgent.value as AiAgent
|
||||
|
||||
@@ -625,6 +625,20 @@
|
||||
applied.push('user message input')
|
||||
}
|
||||
|
||||
// A linked step's memory belongs to the agent it links, and a step supplying its own
|
||||
// messages reads them only while memory is off.
|
||||
if (
|
||||
!value.agent &&
|
||||
value.input_transforms['messages'] == undefined &&
|
||||
isUnconfigured(value.input_transforms['memory'])
|
||||
) {
|
||||
value.input_transforms['memory'] = {
|
||||
type: 'static',
|
||||
value: structuredClone(DEFAULT_AGENT_MEMORY)
|
||||
}
|
||||
applied.push('managed memory on')
|
||||
}
|
||||
|
||||
sendUserToast(
|
||||
applied.length > 0
|
||||
? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.`
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
}
|
||||
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined =
|
||||
$state(undefined)
|
||||
// The linked agent's memory, which decides what the step's history row offers.
|
||||
// The linked agent's memory, which decides which history inputs the step offers.
|
||||
let linkedAgentMemory: { memory: unknown } | undefined = $state(undefined)
|
||||
|
||||
let reloadError: string | undefined = $state(undefined)
|
||||
|
||||
@@ -40,8 +40,22 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
description: 'How much of its history the agent sends with each request.',
|
||||
description:
|
||||
'Windmill stores the conversation and sends its last messages with each request.',
|
||||
enumLabels: {
|
||||
off: 'Off',
|
||||
window: 'On',
|
||||
auto: 'On (legacy)',
|
||||
manual: 'Previous messages (legacy)'
|
||||
},
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
title: 'off',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['off'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
title: 'window',
|
||||
@@ -49,18 +63,12 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
kind: { type: 'string', enum: ['window'] },
|
||||
context_length: {
|
||||
type: 'number',
|
||||
title: 'Messages to keep',
|
||||
description: 'Number of most recent messages to load and store.',
|
||||
default: 10
|
||||
}
|
||||
},
|
||||
required: ['kind', 'context_length']
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
title: 'off',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['off'] }
|
||||
}
|
||||
}
|
||||
],
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
@@ -68,13 +76,13 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
memory_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Names the memory this step reads and writes, overriding the memory id the run was started with.',
|
||||
'Names the memory this step reads and writes, overriding the memory id the run was started with. Read only while managed memory is on.',
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Messages sent before the user message, supplied by this flow instead of read from memory.',
|
||||
'History the flow supplies, sent before the user message. Read only while managed memory is off.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -162,6 +170,38 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
]
|
||||
}
|
||||
|
||||
/** Memory shapes older editors wrote. The step form offers one only to a step that still holds it,
|
||||
* since the one-of field rewrites a value that matches none of its options. */
|
||||
export const LEGACY_MEMORY_VARIANTS: Record<string, any> = {
|
||||
auto: {
|
||||
type: 'object',
|
||||
title: 'auto',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['auto'] },
|
||||
context_length: { type: 'number', title: 'Messages to keep', default: 10 },
|
||||
memory_id: { type: 'string', title: 'Fixed memory id' }
|
||||
},
|
||||
required: ['kind']
|
||||
},
|
||||
manual: {
|
||||
type: 'object',
|
||||
title: 'manual',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['manual'] },
|
||||
messages: { type: 'array', items: AI_AGENT_SCHEMA.properties?.messages?.items }
|
||||
},
|
||||
required: ['kind', 'messages']
|
||||
}
|
||||
}
|
||||
|
||||
/** The memory property to render for a value: a legacy kind is added as an option only while the
|
||||
* value holds it. */
|
||||
export function memoryPropertyFor(property: any, value: any): any {
|
||||
const legacy = value?.kind ? LEGACY_MEMORY_VARIANTS[value.kind] : undefined
|
||||
if (!legacy || !property?.oneOf) return property
|
||||
return { ...property, oneOf: [...property.oneOf, legacy] }
|
||||
}
|
||||
|
||||
function migrateAiAgentInputTransforms(
|
||||
inputTransforms: Record<string, InputTransform>
|
||||
): Record<string, InputTransform> {
|
||||
|
||||
@@ -198,7 +198,8 @@ export async function createBranchAll(id: string): Promise<[FlowModule, FlowModu
|
||||
|
||||
export async function createAiAgent(
|
||||
id: string,
|
||||
agentPath?: string
|
||||
agentPath?: string,
|
||||
chatInputEnabled = false
|
||||
): Promise<[FlowModule, FlowModuleState]> {
|
||||
const storedConfig = loadStoredConfig()
|
||||
const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' }
|
||||
@@ -216,7 +217,15 @@ export async function createAiAgent(
|
||||
? {}
|
||||
: {
|
||||
provider: { type: 'static', value: providerValue },
|
||||
memory: { type: 'static', value: structuredClone(DEFAULT_AGENT_MEMORY) }
|
||||
// A chat agent answers a conversation, so it remembers it from the start.
|
||||
...(chatInputEnabled
|
||||
? {
|
||||
memory: {
|
||||
type: 'static' as const,
|
||||
value: structuredClone(DEFAULT_AGENT_MEMORY)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}),
|
||||
user_message: { type: 'static', value: undefined }
|
||||
}
|
||||
@@ -493,7 +502,11 @@ export async function createNewModule(
|
||||
} else if (kind == 'branchall') {
|
||||
;[module, state] = await createBranchAll(module.id)
|
||||
} else if (kind == 'aiagent') {
|
||||
;[module, state] = await createAiAgent(module.id, agentPath)
|
||||
;[module, state] = await createAiAgent(
|
||||
module.id,
|
||||
agentPath,
|
||||
flowStore.val.value?.chat_input_enabled ?? false
|
||||
)
|
||||
} else if (inlineScript) {
|
||||
const { language, kind, subkind, summary } = inlineScript
|
||||
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
|
||||
|
||||
@@ -153,8 +153,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 reads as unset at
|
||||
* runtime, so it is not persisted either.
|
||||
* 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.
|
||||
*/
|
||||
export function normalizeAgentHistory(
|
||||
inputTransforms: Record<string, any> | undefined,
|
||||
@@ -175,6 +175,10 @@ export function normalizeAgentHistory(
|
||||
if (memoryId?.type === 'static' && !String(memoryId.value ?? '').trim()) {
|
||||
delete inputTransforms.memory_id
|
||||
}
|
||||
const messages = inputTransforms.messages
|
||||
if (messages?.type === 'static' && !messages.value?.length) {
|
||||
delete inputTransforms.messages
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultExpr(
|
||||
|
||||
@@ -70,8 +70,11 @@ describe('normalizeAgentHistory', () => {
|
||||
expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 })
|
||||
})
|
||||
|
||||
it('does not persist an empty static memory id', () => {
|
||||
const transforms: Record<string, any> = { memory_id: { type: 'static', value: ' ' } }
|
||||
it('does not persist an empty static memory id or message list', () => {
|
||||
const transforms: Record<string, any> = {
|
||||
memory_id: { type: 'static', value: ' ' },
|
||||
messages: { type: 'static', value: [] }
|
||||
}
|
||||
normalizeAgentHistory(transforms, false)
|
||||
expect(transforms).toEqual({})
|
||||
})
|
||||
|
||||
@@ -604,7 +604,7 @@ components:
|
||||
- messages
|
||||
|
||||
MemoryConfig:
|
||||
description: How much of its memory the agent sends with each request. The memory itself is named by a memory id, see `memory_id`.
|
||||
description: Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `messages`.
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/MemoryOff'
|
||||
- $ref: '#/components/schemas/MemoryWindow'
|
||||
@@ -1085,16 +1085,15 @@ components:
|
||||
was started with (the chat conversation, an app chat session or the `memory_id` run
|
||||
parameter). Leave unset to use the run's memory id. A fixed value shares one memory
|
||||
across every run; an expression such as `flow_input.customer_id` keeps one memory per
|
||||
key. When it evaluates to an empty value the agent runs without memory. Ignored when
|
||||
`memory` is off. Mutually exclusive with `messages`.
|
||||
key. When it evaluates to an empty value the agent runs without memory. Read only
|
||||
while `memory` keeps messages; ignored when it is off.
|
||||
messages:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
description: |
|
||||
Array of MemoryMessage. History supplied by the flow, sent between the system prompt
|
||||
and the user message. Memory is neither read nor written, also when an expression
|
||||
evaluates to null, which sends no history. Takes precedence over `memory_id` and
|
||||
`memory`.
|
||||
and the user message. Read only while `memory` is off; ignored while it keeps
|
||||
messages. Replaces the list of a deprecated `manual` memory.
|
||||
output_schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/InputTransform'
|
||||
|
||||
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
Reference in New Issue
Block a user