Compare commits

...
Author SHA1 Message Date
hugocasaandClaude Fable 5.1 c84dd03c86 fix: drop flow-local keys from a linked agent resource before interpolating it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:02:15 +02:00
hugocasaandClaude Fable 5.1 0d02a31555 fix: address review on cleared test history and zero-count memory
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:45:41 +02:00
hugocasaandClaude Fable 5.1 74bcf90ca0 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>
2026-09-16 11:27:23 +02:00
hugocasaandClaude Opus 5 24feb11cef fix: name the memory setting in ignored-input notes and keep conversions honest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:57:30 +02:00
hugocasaandClaude Opus 5 4e2eec4c64 refactor: read agent memory as either a legacy shape or the current one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:30:13 +02:00
hugocasaandClaude Opus 5 7bbd0b65de style: rewrap comments and docs lines lengthened by the previous_messages rename
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:01:41 +02:00
hugocasaandClaude Opus 5 88d9bb00eb fix: rename step messages to previous_messages and address review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:22:05 +02:00
hugocasaandClaude Opus 5 ad15cb740b fix: keep legacy auto memory without an id working after an untouched redeploy
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:47:24 +02:00
hugocasaandClaude Opus 5 091e1128a3 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>
2026-09-15 18:40:14 +02:00
hugocasa e75c8acac7 Merge remote-tracking branch 'origin/main' into hugo/agent-memory-refactor
# Conflicts:
#	backend/ee-repo-ref.txt
#	frontend/src/lib/components/InstanceSettings.svelte
2026-09-15 17:58:19 +02:00
hugocasaandClaude Opus 5 6ac5ce2d70 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>
2026-09-15 17:57:32 +02:00
hugocasa 4c14ed55b0 Merge remote-tracking branch 'origin/main' into hugo/agent-memory-refactor
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-ai/src/types.rs
#	backend/windmill-worker/src/ai_executor.rs
#	cli/src/guidance/skills.gen.ts
#	docs/reusable-ai-agents.md
#	frontend/src/lib/components/InstanceSettings.svelte
#	frontend/src/lib/components/ModulePreviewForm.svelte
#	frontend/src/lib/components/copilot/chat/flow/openFlow.json
#	frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts
#	frontend/src/lib/components/flows/agentFormFields.test.ts
#	frontend/src/lib/components/flows/agentResourceUtils.test.ts
#	frontend/src/lib/components/flows/agentResourceUtils.ts
#	frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte
#	frontend/src/lib/components/flows/content/FlowInput.svelte
#	frontend/src/lib/components/flows/content/FlowModuleComponent.svelte
#	openflow.openapi.yaml
#	system_prompts/auto-generated/flow.md
#	system_prompts/auto-generated/prompts.ts
#	system_prompts/auto-generated/skills/write-flow/SKILL.md
2026-09-15 17:23:47 +02:00
hugocasaandClaude Opus 5 e225e67bdf feat: managed memory with an inherited or custom memory id per step
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:19:13 +02:00
hugocasa 796bedbb68 docs: keep the memory storage path on one line 2026-09-14 18:37:48 +02:00
hugocasa 39cdb0fbb6 docs: name the history inputs wherever linked steps list their flow-local inputs 2026-09-14 18:37:23 +02:00
hugocasa fde63dfb09 chore: keep the empty messages comment within the line width 2026-09-14 18:30:03 +02:00
hugocasa ed503366db fix: require a user message when provided messages are empty 2026-09-14 18:26:25 +02:00
hugocasa cc9a4213b3 fix: bypass memory when a provided messages expression evaluates to null 2026-09-14 18:21:30 +02:00
hugocasa a69fb13591 fix: seed provided messages from legacy manual memory and hide its note once set 2026-09-14 18:16:58 +02:00
hugocasa 3f7e3cdf26 fix: offer no memory id for legacy manual memory, document linked history inputs 2026-09-14 18:05:35 +02:00
hugocasa 14218070e6 chore: regenerate openflow json after merging main 2026-09-14 17:33:36 +02:00
hugocasa 6f72c71295 Merge remote-tracking branch 'origin/main' into hugo/agent-memory-refactor
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-14 17:32:54 +02:00
hugocasa f928a9a05e docs: describe memory id scoping as collision-free rather than isolated 2026-09-14 17:32:31 +02:00
hugocasa 73d04a1776 chore: update sqlx cache for the flow context query 2026-09-14 17:28:32 +02:00
hugocasa 06a47d7a0e fix: scope string memory ids to workspace and flow, keep nested tool history inputs 2026-09-14 17:26:37 +02:00
hugocasa 8850c1db70 feat: split ai agent memory into agent policy, run memory id and step history 2026-09-14 17:05:21 +02:00
41 changed files with 1627 additions and 361 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n j.args as \"args: Json<HashMap<String, Box<RawValue>>>\",\n js.flow_status as \"flow_status: Json<windmill_common::flow_status::FlowStatus>\"\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ",
"query": "\n SELECT\n j.args as \"args: Json<HashMap<String, Box<RawValue>>>\",\n js.flow_status as \"flow_status: Json<windmill_common::flow_status::FlowStatus>\",\n j.runnable_path\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "flow_status: Json<windmill_common::flow_status::FlowStatus>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "runnable_path",
"type_info": "Varchar"
}
],
"parameters": {
@@ -20,9 +25,10 @@
]
},
"nullable": [
true,
true,
true
]
},
"hash": "dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103"
"hash": "9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b"
}
+1
View File
@@ -15654,6 +15654,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yml",
"sha1",
"sha2 0.10.9",
"size",
"spki",
+57 -7
View File
@@ -78,17 +78,50 @@ impl Default for OutputType {
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Memory {
Off,
Auto {
#[serde(default)]
Window {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_length: usize,
#[serde(default)]
},
/// Written before `window`. Its `memory_id` stays a fallback behind the run's memory id.
Auto {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_length: usize,
#[serde(default, deserialize_with = "deserialize_blank_as_none")]
memory_id: Option<Uuid>,
},
/// Written before a step had history inputs of its own, and read on its own where it remains.
Manual {
messages: Vec<OpenAIMessage>,
},
}
// An editor form can leave `""` in a legacy baked id it never filled; it means no id rather than
// failing every run of the step.
fn deserialize_blank_as_none<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Uuid>, D::Error> {
match <Option<String> as serde::Deserialize>::deserialize(deserializer)? {
Some(id) if !id.trim().is_empty() => Uuid::parse_str(id.trim())
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
// A count the editor's number field was cleared of is stored as `null`, which `default` does not
// cover; it reads as 0, memory off, rather than failing every run of the step.
fn deserialize_null_as_zero<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<usize, D::Error> {
<Option<usize> as serde::Deserialize>::deserialize(deserializer).map(Option::unwrap_or_default)
}
fn deserialize_present<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Value>, D::Error> {
<serde_json::Value as serde::Deserialize>::deserialize(deserializer).map(Some)
}
#[derive(Deserialize)]
struct AIAgentArgsRaw {
provider: ProviderWithResource,
@@ -103,6 +136,12 @@ struct AIAgentArgsRaw {
streaming: Option<bool>,
max_iterations: Option<usize>,
memory: Option<Memory>,
// A null must stay distinguishable from an absent key: a step whose own memory id evaluates to
// 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>,
#[serde(default)]
previous_messages: Option<Vec<OpenAIMessage>>,
enabled_tools: Option<Vec<String>>,
// Legacy field for backward compatibility
messages_context_length: Option<usize>,
@@ -124,6 +163,10 @@ pub struct AIAgentArgs {
pub streaming: Option<bool>,
pub max_iterations: Option<usize>,
pub memory: Option<Memory>,
/// Memory id set on the step, overriding the run's. Empty when its expression produced none.
pub memory_id: Option<String>,
/// History supplied by the flow, replayed without reading or writing memory.
pub previous_messages: Option<Vec<OpenAIMessage>>,
/// Which of the agent's tools this run may call; `narrow_roster` holds what the names are and
/// what `None` means.
pub enabled_tools: Option<Vec<String>>,
@@ -139,12 +182,17 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
});
// Backward compatibility: if context_length is 0, use off mode
let memory = memory.map(|memory| {
if let Memory::Auto { context_length: 0, .. } = memory {
let memory = memory.map(|memory| match memory {
Memory::Auto { context_length: 0, .. } | Memory::Window { context_length: 0 } => {
Memory::Off
} else {
memory
}
memory => memory,
});
let memory_id = raw.memory_id.map(|value| match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.trim().to_string(),
value => value.to_string(),
});
AIAgentArgs {
@@ -159,6 +207,8 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
streaming: raw.streaming,
max_iterations: raw.max_iterations,
memory,
memory_id,
previous_messages: raw.previous_messages,
enabled_tools: raw.enabled_tools,
credentials_check: raw.credentials_check.unwrap_or(false),
}
+5 -3
View File
@@ -653,9 +653,11 @@ pub async fn set_flow_memory_id(
pub async fn process_flow_run_query_params(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
job_id: Uuid,
w_id: &str,
flow_path: &str,
run_query: &RunJobQuery,
) -> error::Result<()> {
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(w_id, flow_path) {
set_flow_memory_id(tx, job_id, memory_id).await?;
}
Ok(())
@@ -669,7 +671,7 @@ pub async fn handle_chat_conversation_messages(
run_query: &RunJobQuery,
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
) -> error::Result<()> {
let memory_id = run_query.memory_id.ok_or_else(|| {
let memory_id = run_query.memory_key(w_id, flow_path).ok_or_else(|| {
windmill_common::error::Error::BadRequest(
"memory_id is required for chat-enabled flows".to_string(),
)
@@ -813,7 +815,7 @@ pub async fn run_flow<'c>(
.await?;
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(w_id, flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
+13 -1
View File
@@ -47,13 +47,25 @@ pub struct RunJobQuery {
pub cache_ignore_s3_path: Option<bool>,
pub skip_preprocessor: Option<bool>,
pub poll_delay_ms: Option<u64>,
pub memory_id: Option<Uuid>,
/// Any string; see [`RunJobQuery::memory_key`].
pub memory_id: Option<String>,
pub trigger_external_id: Option<String>,
pub service_name: Option<String>,
pub suspended_mode: Option<bool>,
}
impl RunJobQuery {
/// The memory id as stored in `flow_status.memory_id`: a uuid is kept, any other string hashed
/// within the workspace and the flow being run.
pub fn memory_key(&self, workspace_id: &str, flow_path: &str) -> Option<Uuid> {
self.memory_id
.as_deref()
.filter(|memory_id| !memory_id.trim().is_empty())
.map(|memory_id| {
windmill_common::flow_conversations::memory_key(workspace_id, flow_path, memory_id)
})
}
pub async fn get_scheduled_for(
&self,
db: &DB,
+11 -22
View File
@@ -11236,11 +11236,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: script args
@@ -11277,11 +11276,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: script args
@@ -11318,11 +11316,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
responses:
"200":
@@ -11345,11 +11342,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11387,11 +11383,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11427,11 +11422,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11475,11 +11469,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -14804,11 +14797,10 @@ paths:
schema:
type: boolean
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: flow args
required: true
@@ -14862,11 +14854,10 @@ paths:
schema:
type: boolean
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: flow args
required: true
@@ -15354,11 +15345,10 @@ paths:
type: boolean
- $ref: "#/components/parameters/NewJobId"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
@@ -15386,11 +15376,10 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
+7 -6
View File
@@ -4304,11 +4304,11 @@ async fn execute_component(
}
}
let is_flow = payload
let flow_path = payload
.path
.as_ref()
.map(|p| p.starts_with("flow/"))
.unwrap_or(false);
.as_deref()
.and_then(|path| path.strip_prefix("flow/"))
.map(str::to_string);
// Tag for inline-script jobs is read from the deployed policy in run mode;
// only preview mode (editor) honors the client-supplied tag. This applies to
@@ -4438,8 +4438,9 @@ async fn execute_component(
// Apply runnable query parameters if provided
if let Some(ref run_query) = payload.run_query_params {
if is_flow {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?;
if let Some(flow_path) = flow_path.as_deref() {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, &w_id, flow_path, run_query)
.await?;
}
}
+1 -1
View File
@@ -9540,7 +9540,7 @@ async fn run_preview_flow_job(
.await?;
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
+1
View File
@@ -33,6 +33,7 @@ path = "src/lib.rs"
tar.workspace = true
hmac.workspace = true
sha2.workspace = true
sha1.workspace = true
thiserror.workspace = true
anyhow.workspace = true
serde.workspace = true
@@ -7,6 +7,29 @@ use crate::db::DB;
use crate::error::Result;
use crate::utils::truncate_with_ellipsis;
/// Changing it detaches every memory stored under a string memory id.
const MEMORY_ID_NAMESPACE: Uuid = Uuid::from_u128(0x6f1c2d4e_8a3b_5c7d_9e0f_1a2b3c4d5e6f);
/// Memory is stored and carried in `flow_status.memory_id` as a uuid, which names the same memory
/// wherever it is passed, as a chat conversation id must. Any other string names a memory through a
/// name-based (v5) uuid scoped to its workspace and flow, so the same key in two flows or two
/// workspaces names two memories, and chat conversation ids stay unique across workspaces.
pub fn memory_key(workspace_id: &str, flow_path: &str, memory_id: &str) -> Uuid {
let memory_id = memory_id.trim();
Uuid::parse_str(memory_id).unwrap_or_else(|_| {
use sha1::{Digest, Sha1};
let mut hasher = Sha1::new();
hasher.update(MEMORY_ID_NAMESPACE.as_bytes());
for part in [workspace_id, flow_path, memory_id] {
hasher.update(part.as_bytes());
hasher.update([0u8]);
}
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hasher.finalize()[..16]);
uuid::Builder::from_sha1_bytes(bytes).into_uuid()
})
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "MESSAGE_TYPE", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
@@ -143,3 +166,26 @@ pub async fn delete_conversation_memory(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A string names a memory only within its workspace and flow; a uuid is used as is.
#[test]
fn memory_key_scopes_strings_but_not_uuids() {
let key = memory_key("ws", "f/support/triage", " customer-1 ");
assert_eq!(key, memory_key("ws", "f/support/triage", "customer-1"));
assert_ne!(
key,
memory_key("other_ws", "f/support/triage", "customer-1")
);
assert_ne!(key, memory_key("ws", "f/sales/triage", "customer-1"));
let conversation = Uuid::from_u128(7).to_string();
assert_eq!(memory_key("ws", "f/a", &conversation), Uuid::from_u128(7));
assert_eq!(
memory_key("other_ws", "f/b", &conversation),
Uuid::from_u128(7)
);
}
}
+2 -1
View File
@@ -1095,7 +1095,8 @@ pub enum FlowModuleValue {
omit_output_from_conversation: bool,
/// When set, the agent brain config (provider/model/system prompt/etc.) and tools are
/// resolved at runtime from this `ai_agent` resource path (hybrid linking). The module's
/// `input_transforms` then only carry the flow-local inputs (user_message/user_attachments).
/// `input_transforms` then only carry the flow-local inputs: user_message,
/// user_attachments, enabled_tools and the history inputs memory_id and previous_messages.
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
/// Binds an agent's tools to *this* flow's context, keyed by tool id then input key, without
+5 -1
View File
@@ -154,6 +154,8 @@ pub async fn get_flow_job_runnable_and_raw_flow(
pub struct FlowContext {
pub flow_inputs: Option<HashMap<String, Box<RawValue>>>,
pub flow_status: Option<windmill_common::flow_status::FlowStatus>,
/// Path of the flow the run started from, which scopes a string memory id.
pub flow_path: Option<String>,
}
/// Get flow context (chat settings + args + flow_status) from root flow's job data
@@ -171,7 +173,8 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
r#"
SELECT
j.args as "args: Json<HashMap<String, Box<RawValue>>>",
js.flow_status as "flow_status: Json<windmill_common::flow_status::FlowStatus>"
js.flow_status as "flow_status: Json<windmill_common::flow_status::FlowStatus>",
j.runnable_path
FROM v2_job_status js
INNER JOIN v2_job j ON j.id = js.id
WHERE js.id = $1
@@ -184,6 +187,7 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
Ok(Some(row)) => FlowContext {
flow_inputs: row.args.map(|j| j.0),
flow_status: row.flow_status.map(|j| j.0),
flow_path: row.runnable_path,
},
Ok(None) => {
tracing::warn!(
+458 -88
View File
@@ -40,7 +40,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::{self, Error},
flow_conversations::MessageType,
flow_conversations::{memory_key, MessageType},
flow_status::AgentAction,
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
@@ -107,6 +107,145 @@ fn prepare_auto_memory_messages_for_persistence(
non_system_messages[start_idx..].to_vec()
}
/// The inputs a linked step supplies for itself; the resource holds the rest of the brain.
const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [
"user_message",
"user_attachments",
"enabled_tools",
"memory_id",
"previous_messages",
];
/// Where one agent invocation's history comes from.
#[derive(Debug)]
enum HistorySource<'a> {
/// Supplied by the flow and replayed as is: memory is neither read nor written.
Messages(&'a [OpenAIMessage]),
Window {
memory_id: Uuid,
context_length: usize,
},
Stateless,
}
/// A step's memory id counts only as the step authored it. A static empty value is a form
/// placeholder, so it reads as unset rather than as an expression that evaluated to nothing, which
/// runs without memory; an AI-filled value would let the model choose which memory the agent reads.
fn keep_authored_memory_id(
args: &mut AIAgentArgs,
step_input_transforms: &HashMap<String, InputTransform>,
) {
match step_input_transforms.get("memory_id") {
Some(InputTransform::Javascript { .. }) => {}
Some(InputTransform::Static { .. }) if args.memory_id.as_deref() != Some("") => {}
_ => args.memory_id = None,
}
}
/// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step
/// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it
/// meant it, or the current setting plus the step's own history inputs. Also returns lines for the
/// job log: an 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>, Vec<&'static str>) {
let mut notes = Vec::new();
let no_memory_id = "No memory id was passed to this run, so the agent runs without memory.";
match &args.memory {
// The step's own history inputs came after these, so a step that still holds one reads it
// alone: what it did before the editor offered them is what it keeps doing.
Some(Memory::Manual { messages }) => {
note_unread_step_inputs(&mut notes, args);
(HistorySource::Messages(messages), notes)
}
Some(Memory::Auto { context_length, memory_id }) => {
note_unread_step_inputs(&mut notes, args);
// An id baked in at save time only ever applied when the run carried none.
match run_memory_id.or(*memory_id) {
Some(memory_id) => (
HistorySource::Window { memory_id, context_length: *context_length },
notes,
),
None => {
notes.push(no_memory_id);
(HistorySource::Stateless, notes)
}
}
}
Some(Memory::Window { context_length }) => {
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes.push("Managed memory is on, so this step's previous 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 {
Some(memory_id) => memory_id,
None => {
notes.push(no_memory_id);
return (HistorySource::Stateless, notes);
}
},
};
(
HistorySource::Window { memory_id, context_length: *context_length },
notes,
)
}
Some(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.");
}
match &args.previous_messages {
Some(messages) => (HistorySource::Messages(messages), notes),
None => (HistorySource::Stateless, notes),
}
}
}
}
/// An older memory setting reads neither history input, which is only visible in the job log: the
/// editor offers them on a step that has been moved to the current settings.
fn note_unread_step_inputs(notes: &mut Vec<&'static str>, args: &AIAgentArgs) {
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
notes.push("This step uses an older memory setting, so its memory id is not read.");
}
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes
.push("This step uses an older memory setting, so its previous messages are not read.");
}
}
/// Whether a request has something to ask the model. Only text output sends previous messages, so
/// an image prompt comes from the user message alone. An empty list is no conversation, except
/// under a legacy `manual` memory, which ran on whatever list it held.
fn has_prompt(
history: &HistorySource,
has_user_message: bool,
is_text_output: bool,
legacy_list: bool,
) -> bool {
has_user_message
|| (is_text_output
&& (legacy_list || matches!(history, HistorySource::Messages(m) if !m.is_empty())))
}
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
@@ -132,14 +271,16 @@ async fn find_ai_agent_tool_module_in_parent_agent(
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, agent, .. } = parent_agent_module.get_value()? else {
let FlowModuleValue::AIAgent { tools, agent, tool_inputs, .. } =
parent_agent_module.get_value()?
else {
return Ok(None);
};
// A linked parent carries no tools on the module (they live in the resource, resolved only in
// the main execution branch). Resolve them from the resource here too, so a nested agent tool
// of a saved+linked agent can still be located when it runs as its own job.
let tools = if let Some(agent_ref) = agent.as_deref() {
let mut tools = if let Some(agent_ref) = agent.as_deref() {
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
@@ -166,6 +307,9 @@ async fn find_ai_agent_tool_module_in_parent_agent(
} else {
tools
};
// The nested job reads its history inputs from the tool's transforms, which must carry the
// host flow's bindings as the parent evaluated them.
overlay_tool_inputs(&mut tools, &tool_inputs);
for tool in tools {
if tool.id == tool_module_id {
@@ -452,6 +596,7 @@ pub async fn handle_ai_agent_job(
omit_output_from_conversation,
agent,
tool_inputs,
input_transforms: step_input_transforms,
..
} = module.get_value()?
else {
@@ -462,9 +607,11 @@ pub async fn handle_ai_agent_job(
// A linked step takes its brain and tools from the resource and keeps only its own flow-local
// inputs. The brain and the roster stay rigid; what the step binds to this flow is the message
// it asks, which of those tools this use may call, the conversation it is part of, and the
// tools' own inputs — the last overlaid from `tool_inputs` below.
let (args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref() {
// it asks, which of those tools this use may call, the conversation it is part of (its memory
// id and previous messages), and the tools' own inputs — the last overlaid from `tool_inputs`
// below.
let (mut args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref()
{
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
@@ -496,6 +643,11 @@ pub async fn handle_ai_agent_job(
None => Vec::new(),
};
overlay_tool_inputs(&mut tools, &tool_inputs);
// The resource is not validated against a schema, so a flow-local key it happens to carry
// is dropped before interpolation, where a bad `$res:` in it would fail the step.
for key in FLOW_LOCAL_AGENT_KEYS {
config.remove(key);
}
let brain = transform_json_value(
"ai_agent",
client,
@@ -517,7 +669,7 @@ 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.
for key in ["user_message", "user_attachments", "enabled_tools"] {
for key in FLOW_LOCAL_AGENT_KEYS {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
@@ -542,6 +694,8 @@ pub async fn handle_ai_agent_job(
(args, tools)
};
keep_authored_memory_id(&mut args, &step_input_transforms);
// Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool,
// a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a
// shared resource); don't advertise them — invoking one would only fail the depth check as a
@@ -1006,8 +1160,18 @@ pub async fn run_agent(
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
// Determine if we're using manual messages (which bypasses memory)
let use_manual_messages = matches!(args.memory, Some(Memory::Manual { .. }));
// The run's memory id is also the chat conversation id, which a step's own memory id never
// replaces.
let conversation_id = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.memory_id);
let (history, history_notes) = resolve_history_source(
args,
conversation_id,
&job.workspace_id,
flow_context.flow_path.as_deref().unwrap_or_default(),
);
// Check if user_message is provided and non-empty
let has_user_message = args
@@ -1016,63 +1180,52 @@ pub async fn run_agent(
.map(|m| !m.is_empty())
.unwrap_or(false);
// Validate: at least one of memory with manual messages or user_message must be provided
if !use_manual_messages && !has_user_message {
return Err(Error::internal_err(
"Either 'memory' with manual messages or 'user_message' must be provided".to_string(),
));
}
let is_text_output = output_type == &OutputType::Text;
// Flow-level memory_id (from chat mode) takes precedence over step-level memory_id
let memory_id = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.memory_id)
.or_else(|| {
// Extract memory_id from Memory::Auto if present
match &args.memory {
Some(Memory::Auto { memory_id, .. }) => *memory_id,
_ => None,
}
});
if is_text_output {
for note in &history_notes {
append_logs(&job.id, &job.workspace_id, format!("{note}\n"), conn).await;
}
}
// A `manual` memory sent whatever list it held, an empty one included, so a step that still has
// one keeps running without a user message.
let legacy_list = matches!(args.memory, Some(Memory::Manual { .. }));
if !has_prompt(&history, has_user_message, is_text_output, legacy_list) {
let missing = if !is_text_output {
"'user_message' must be provided for image output"
} else if matches!(
args.memory,
Some(Memory::Window { .. } | Memory::Auto { .. })
) {
"'user_message' must be provided while managed memory is on"
} else {
"Either 'previous_messages' or 'user_message' must be provided"
};
return Err(Error::internal_err(missing.to_string()));
}
// Load messages based on history mode
if matches!(output_type, OutputType::Text) {
match &args.memory {
Some(Memory::Manual { messages: manual_messages }) => {
// Use explicitly provided messages (bypass memory)
if !manual_messages.is_empty() {
messages.extend(manual_messages.clone());
}
}
Some(Memory::Auto { context_length, .. }) => {
// Auto mode: load from memory
match &history {
HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()),
HistorySource::Window { memory_id, context_length } => {
if let Some(step_id) = effective_flow_step_id {
if let Some(memory_id) = memory_id {
// Read messages from memory
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
*context_length,
);
messages.extend(messages_to_load);
}
Ok(None) => {}
Err(e) => {
tracing::error!(
"Failed to read memory for step {}: {}",
step_id,
e
);
}
match read_from_memory(db, &job.workspace_id, *memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
*context_length,
);
messages.extend(messages_to_load);
}
Ok(None) => {}
Err(e) => {
tracing::error!("Failed to read memory for step {}: {}", step_id, e);
}
}
}
}
_ => {}
HistorySource::Stateless => {}
}
}
@@ -1500,7 +1653,7 @@ pub async fn run_agent(
..Default::default()
});
if persist_output_to_conversation {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
let message_content = "Used websearch tool successfully".to_string();
@@ -1508,7 +1661,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Tool,
@@ -1519,7 +1672,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add websearch tool message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1552,7 +1705,7 @@ pub async fn run_agent(
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation && !response_content.is_empty() {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
let message_content = response_content.clone();
@@ -1562,7 +1715,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Assistant,
@@ -1573,7 +1726,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1671,7 +1824,7 @@ pub async fn run_agent(
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
@@ -1689,7 +1842,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Assistant,
@@ -1700,7 +1853,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1757,13 +1910,10 @@ pub async fn run_agent(
}
}
// Persist complete conversation to memory at the end (only if in auto mode with context length)
// Skip memory persistence if using manual messages (bypass memory entirely)
// final_messages contains the complete history (old messages + new ones)
if matches!(output_type, OutputType::Text) && !use_manual_messages {
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
// final_messages holds the complete history: what was loaded plus this run's messages
if matches!(output_type, OutputType::Text) {
if let HistorySource::Window { memory_id, context_length } = &history {
if let Some(step_id) = effective_flow_step_id {
// Extract OpenAIMessages from final_messages
let all_messages: Vec<OpenAIMessage> =
final_messages.iter().map(|m| m.message.clone()).collect();
@@ -1773,23 +1923,21 @@ pub async fn run_agent(
*context_length,
);
if let Some(memory_id) = memory_id {
if let Err(e) = write_to_memory(
db,
&job.workspace_id,
memory_id,
if let Err(e) = write_to_memory(
db,
&job.workspace_id,
*memory_id,
step_id,
&messages_to_persist,
)
.await
{
tracing::error!(
"Failed to persist {} messages to memory for step {}: {}",
messages_to_persist.len(),
step_id,
&messages_to_persist,
)
.await
{
tracing::error!(
"Failed to persist {} messages to memory for step {}: {}",
messages_to_persist.len(),
step_id,
e
);
}
e
);
}
}
}
@@ -1849,6 +1997,228 @@ mod tests {
}
}
#[derive(Debug, PartialEq)]
enum Resolved {
Messages(usize),
Window(Uuid, usize),
Stateless { noted: bool },
}
/// Every memory shape a worker may still read, resolved against a run with or without a
/// memory id. The hashed id is pinned: changing it detaches memories stored under string ids.
#[test]
fn history_source_resolves_every_memory_shape() {
use serde_json::json;
let run = Uuid::from_u128(1);
let baked = Uuid::from_u128(2);
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",
json!({}),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy off",
json!({ "memory": { "kind": "off" } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy auto prefers the run's id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto falls back to its baked id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
None,
Resolved::Window(baked, 4),
),
(
"legacy auto with an empty baked id uses the run's",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": "" } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto with an empty baked id and no run id is stateless",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": " " } }),
None,
Resolved::Stateless { noted: true },
),
(
"legacy auto without a length is off",
json!({ "memory": { "kind": "auto", "memory_id": baked } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a cleared count is off",
json!({ "memory": { "kind": "window", "context_length": null } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy manual replays its messages",
json!({ "memory": { "kind": "manual", "messages": message } }),
Some(run),
Resolved::Messages(1),
),
(
"window keeps the run's memory",
json!({ "memory": window }),
Some(run),
Resolved::Window(run, 10),
),
(
"window without a memory id is stateless",
json!({ "memory": window }),
None,
Resolved::Stateless { noted: true },
),
(
"a step memory id overrides the run's",
json!({ "memory": window, "memory_id": "cust_1" }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"a uuid step memory id is used as is",
json!({ "memory": window, "memory_id": baked.to_string() }),
Some(run),
Resolved::Window(baked, 10),
),
(
"a step memory id evaluating to null is stateless",
json!({ "memory": window, "memory_id": null }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"an off policy ignores the step memory id, and says so",
json!({ "memory": { "kind": "off" }, "memory_id": "cust_1" }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"managed memory ignores the step's previous messages",
json!({ "memory": window, "memory_id": "cust_1", "previous_messages": message }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"memory that is off sends the step's previous messages",
json!({ "previous_messages": message }),
Some(run),
Resolved::Messages(1),
),
(
"a previous messages expression that evaluated to null is no history",
json!({ "previous_messages": null }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a legacy manual list ignores the step's previous messages",
json!({ "memory": { "kind": "manual", "messages": message }, "previous_messages": two_messages }),
Some(run),
Resolved::Messages(1),
),
(
"legacy auto ignores a step memory id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked }, "memory_id": "cust_1" }),
None,
Resolved::Window(baked, 4),
),
];
for (name, history, run_memory_id, expected) in cases {
let mut raw = json!({ "provider": { "kind": "openai", "resource": {}, "model": "m" } });
raw.as_object_mut()
.unwrap()
.extend(history.as_object().unwrap().clone());
let args: AIAgentArgs = serde_json::from_value(raw).unwrap();
let resolved = match resolve_history_source(&args, run_memory_id, "ws", "f/flow") {
(HistorySource::Messages(m), _) => Resolved::Messages(m.len()),
(HistorySource::Window { memory_id, context_length }, _) => {
Resolved::Window(memory_id, context_length)
}
(HistorySource::Stateless, notes) => {
Resolved::Stateless { noted: !notes.is_empty() }
}
};
assert_eq!(resolved, expected, "{name}");
}
}
/// A placeholder the form seeds must not read as a memory id that evaluated to nothing, which
/// would turn memory off for the step.
#[test]
fn only_an_expression_can_set_an_empty_step_memory_id() {
let transforms = |memory_id: &str| -> HashMap<String, InputTransform> {
HashMap::from([(
"memory_id".to_string(),
serde_json::from_str(memory_id).unwrap(),
)])
};
let args = || -> AIAgentArgs {
serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"memory_id": null,
}))
.unwrap()
};
for (transform, expected) in [
(r#"{ "type": "static" }"#, None),
(r#"{ "type": "static", "value": "" }"#, None),
(r#"{ "type": "ai" }"#, None),
(
r#"{ "type": "javascript", "expr": "flow_input.customer_id" }"#,
Some(""),
),
] {
let mut args = args();
keep_authored_memory_id(&mut args, &transforms(transform));
assert_eq!(args.memory_id.as_deref(), expected, "{transform}");
}
}
/// Only text output sends previous messages, so they never stand in for an image prompt.
#[test]
fn previous_messages_never_stand_in_for_an_image_prompt() {
let args: AIAgentArgs = serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"previous_messages": [{ "role": "user", "content": "earlier" }],
}))
.unwrap();
let (history, _) = resolve_history_source(&args, None, "ws", "f/flow");
assert!(has_prompt(&history, false, true, false));
assert!(!has_prompt(&history, false, false, false));
assert!(has_prompt(&history, true, false, false));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
true,
false
));
// A legacy `manual` memory ran on an empty list alone, and still does for text output.
assert!(has_prompt(&HistorySource::Messages(&[]), false, true, true));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
false,
true
));
}
#[test]
fn reasoning_keeps_every_iteration_in_order() {
let mut acc = String::new();
+1 -1
View File
File diff suppressed because one or more lines are too long
+58 -5
View File
@@ -16,11 +16,12 @@ every workspace via the standard cached-resource-type sync, like other built-in
- The brain config and tools are resolved at runtime from the resource
(`windmill-worker/src/ai_executor.rs`): the brain is interpolated, so a nested provider `$res:`
credential resolves automatically.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`)
in its own `input_transforms`; the brain and tools stay in the resource (read-only in the step).
`enabled_tools` says which of the roster this step may call, narrowing one use of a shared agent
without touching the agent: an absent field carries every tool, a list carries the ones it names,
and an empty list carries none.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`,
and the history inputs `memory_id` and `previous_messages`) in its own `input_transforms`; the
brain and tools stay in the resource (read-only in the step). `enabled_tools` says which of the
roster this step may call, narrowing one use of a shared agent without touching the agent: an
absent field carries every tool, a list carries the ones it names, and an empty list carries
none.
- The agent carries its tools' default input bindings verbatim as authored (static, AI-filled,
or flow expressions), so saving round-trips losslessly. Each host flow overrides what it
needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own
@@ -36,6 +37,58 @@ agent step); below the step's inputs, each tool gets a section with the standard
input editors (prop picker included) and a read-only view of its code — edits persist into
`tool_inputs`.
## Memory
Memory is split between three owners, so a saved agent carries whether it remembers and never
which memory it is:
- **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. 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, `previous_messages` supplies
the history itself. An older `auto` or `manual` memory reads neither, so the editor offers them
only once the step is moved to the current settings, which the alert's button does. 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. 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. 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
written, and a step `memory_id` is ignored.
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`)
always follows the run's id, even when a step sets its own. Nothing expires stored memory: deleting
a chat conversation deletes its memory, and a memory named by a string id stays until it is
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 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
The agent editor edits the resource through a **per-user resource draft** (`draft` table,
+2
View File
@@ -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?: {
+5 -1
View File
@@ -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,6 +8,7 @@
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'
@@ -61,6 +62,9 @@
* for a surface whose form cannot open a row at all. A schema key the field registry doesn't
* know is kept, so a new one is never silently dropped. */
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
@@ -71,7 +75,11 @@
for (const key of openAgentFields(openFieldsKey)) visible.add(key)
for (const key of runInputKeys) visible.add(key)
const known = new Set(AGENT_FIELDS.map((f) => f.key))
return all.filter((key) => !known.has(key) || visible.has(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([])
@@ -182,7 +190,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]}
+16 -4
View File
@@ -21,6 +21,7 @@
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { AGENT_HISTORY_KEYS } from './flows/agentFormFields'
import { sendUserToast } from '$lib/toast'
interface Props {
@@ -174,7 +175,20 @@
// 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.
const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
// 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) || !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
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
@@ -182,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
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import { AI_AGENT_SCHEMA } from './flowInfers'
import { AI_AGENT_SCHEMA, memoryOptionLabel, memoryPropertyFor } from './flowInfers'
import {
AGENT_FIELD_BY_KEY,
AGENT_FIELDS,
agentMemoryMode,
historyInputApplies,
agentFieldIsSet,
initialVisibleAgentFields
} from './agentFormFields'
@@ -79,7 +81,69 @@ describe('initialVisibleAgentFields', () => {
})
it('covers every schema key, so no field can only be reached through the raw doc', () => {
const registered = new Set(AGENT_FIELDS.map((f) => f.key))
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()
})
})
@@ -1,5 +1,5 @@
import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
import type { InputTransform, MemoryConfig } from '$lib/gen'
/**
* How the AI agent form presents `AI_AGENT_SCHEMA`: which group a field belongs to, what it is
@@ -25,6 +25,54 @@ 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'
/** 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 author adds them. */
export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const
export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number]
/** What turning managed memory on writes. */
export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 }
/** 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)
}
export type AgentMemoryMode = 'legacy' | 'managed' | 'off'
/** Which shape a run reads this memory as: an older `auto`/`manual` setting, or the current one. */
export function agentMemoryMode(memory: any): AgentMemoryMode {
if (memory?.kind === 'manual') return 'legacy'
// The worker reads an `auto` that keeps no messages as off, history inputs included, so the form
// offers what that run would read.
if (memory?.kind === 'auto') return memory.context_length ? 'legacy' : 'off'
return keepsManagedMemory(memory) ? 'managed' : 'off'
}
/** Whether a run reads this step input, mirroring the worker: managed memory reads only a memory
* id, memory that is off only previous messages, and an older setting neither. A setting the form
* cannot read yet leaves both open. */
export function historyInputApplies(
key: AgentHistoryKey,
mode: AgentMemoryMode | undefined
): boolean {
if (mode === undefined) return true
if (mode === 'legacy') return false
return (key === 'memory_id') === (mode === 'managed')
}
/** A memory setting in words, for a linked agent's summary. */
export function describeMemoryPolicy(memory: any): string {
if (keepsManagedMemory(memory)) return `Last ${memory.context_length} messages`
if (memory?.kind === 'manual') return 'Off, sends previous messages saved with the agent'
return 'Off'
}
export interface AgentFieldSpec {
key: string
group: AgentFieldGroup
@@ -76,6 +124,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',
@@ -86,20 +142,29 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [
{
key: 'memory',
group: 'messages',
label: 'Memory',
tooltip:
'History sent between the system message and the user message. Windmill can keep it for you, or you can supply the messages yourself.',
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: 'user_message',
key: 'memory_id',
group: 'messages',
label: 'User message',
label: 'Memory id',
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
'Conversation history id: runs with the same id share their history. Inherited uses the memory_id the run was started with: the conversation id in chat mode, or the memory_id query parameter otherwise. 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: '',
textOnly: true
},
{
key: 'previous_messages',
group: 'messages',
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',
@@ -77,7 +77,7 @@ describe('summarizeAgentBrain', () => {
output_schema: { type: 'object' } as any
})
expect(rows).toEqual([
{ label: 'Memory', value: 'auto' },
{ label: 'Managed memory', value: 'Last 20 messages' },
{ label: 'Output schema', value: 'configured' }
])
})
@@ -147,8 +147,11 @@ describe('flowLocalInputs', () => {
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' }
@@ -156,6 +159,8 @@ describe('flowLocalInputs', () => {
).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' }
})
})
@@ -1,6 +1,6 @@
import { deepEqual } from 'fast-equals'
import type { InputTransform } from '$lib/gen'
import { AGENT_FIELDS } from './agentFormFields'
import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields'
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are
// intentionally excluded — they are supplied per-flow.
@@ -20,9 +20,16 @@ export const AGENT_BRAIN_KEYS = [
* The inputs a step supplies for itself, whether or not it is linked to a saved agent.
*
* `enabled_tools` is one of them because it narrows one use of an agent rather than the agent:
* saving it into the resource would impose one flow's roster on every flow linking it.
* saving it into the resource would impose one flow's roster on every flow linking it. The history
* inputs are too, since which conversation a step reads belongs to the flow using the agent.
*/
export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments', 'enabled_tools'] as const
export const AGENT_FLOW_LOCAL_KEYS = [
'user_message',
'user_attachments',
'enabled_tools',
'memory_id',
'previous_messages'
] as const
export type AgentTool = Record<string, any>
@@ -177,8 +184,7 @@ export function summarizeAgentBrain(
if (key === 'provider') {
value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured'
} else if (key === 'memory') {
// Memory configs are serialized with a `kind` tag (serde tag = "kind").
value = typeof v === 'object' ? (v.kind ?? v.type ?? 'configured') : String(v)
value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v)
} else if (key === 'output_schema') {
value = 'configured'
} else if (typeof v === 'boolean') {
@@ -1,3 +1,4 @@
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'
@@ -138,7 +139,7 @@ export function createAiAgentTool(id: string): AiAgentTool {
user_message: { type: 'ai' }
}
for (const key of Object.keys(AI_AGENT_SCHEMA.properties ?? {})) {
if (!(key in input_transforms)) {
if (!(key in input_transforms) && !(AGENT_HISTORY_KEYS as readonly string[]).includes(key)) {
;(input_transforms as Record<string, InputTransform>)[key] = {
type: 'static',
value: undefined
@@ -0,0 +1,152 @@
<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 previous 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)
// The older setting never read the step's own memory id, so a conversion that promises the same
// behaviour, or the run's id, drops it rather than bringing it to life. Off keeps ignoring it.
function switchToEquivalent() {
if (on) delete args.memory_id
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 }
} else {
delete args.memory_id
}
args.memory = {
type: 'static',
value: { kind: 'window', context_length: memory?.context_length }
}
}
function moveMessagesToStep() {
args.previous_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 these previous messages inside the memory setting,
and this agent still sends them.
</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}
@@ -49,7 +49,9 @@
moduleId,
opWorkspace = undefined,
flowPath = '',
fromAgentEditor = false
fromAgentEditor = false,
chatInputEnabled = false,
linkedMemory = $bindable()
}: {
agent: string | undefined
inputTransforms: Record<string, InputTransform>
@@ -67,6 +69,9 @@
// backend supports it, but only a flow can author it, and a second editor over a second draft
// is the wrong way in.
fromAgentEditor?: boolean
chatInputEnabled?: boolean
// The linked agent's memory once its config has loaded, for the step's history inputs.
linkedMemory?: { memory: unknown } | undefined
} = $props()
let ws = $derived(opWorkspace ?? $workspaceStore)
@@ -185,6 +190,9 @@
let linkedInfo = $derived(
loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined
)
$effect(() => {
linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined
})
let inheritedTools = $derived(linkedInfo?.tools ?? [])
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
let providerPath = $derived(linkedInfo?.providerPath)
@@ -285,6 +293,20 @@
// saved without a complete one fails on every linked run. Block saving when the provider is
// computed/connected (only a static value can be captured into the resource) or when the static
// value is incomplete (a fresh step defaults to empty resource/model, which is still static).
// A saved agent never carries a memory id, so saving would drop the id this step's runs still fall
// back to and leave them without memory. The author picks what replaces it first. In chat mode the
// conversation id always won, so there the id was never read.
let legacyMemorySaveError = $derived.by(() => {
const memory = inputTransforms?.memory as
| { type?: string; value?: { kind?: string; context_length?: number; memory_id?: string } }
| undefined
const value = memory?.type === 'static' ? memory.value : undefined
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 Managed memory, choose Keep as memory id or Use the run's memory id, then save it as an agent."
})
let providerSaveError = $derived.by(() => {
const t = inputTransforms?.provider as
| { type?: string; value?: { resource?: string; model?: string } }
@@ -316,8 +338,8 @@
// the success toast that would otherwise bury the explanation.
async function persist(path: string, description?: string): Promise<boolean> {
const dropped = nonStaticBrainKeys(inputTransforms)
if (providerSaveError) {
throw new Error(providerSaveError)
if (providerSaveError ?? legacyMemorySaveError) {
throw new Error(providerSaveError ?? legacyMemorySaveError)
}
if (dropped.length > 0) {
sendUserToast(
@@ -328,6 +350,12 @@
// Tool inputs are saved verbatim: the agent carries its tools' default bindings (static, AI or
// flow expressions) as authored. Host flows override per-step via tool_inputs, never here.
const value = inputTransformsToAgentConfig(inputTransforms, tools)
// An id an older editor baked into this step names the flow's memory. The agent is shared by
// every step linking it, and each of those takes its memory id from its own run.
if (value.memory && typeof value.memory === 'object' && 'memory_id' in value.memory) {
const { memory_id: _, ...memory } = value.memory as Record<string, unknown>
value.memory = memory
}
// The editor stays live during the requests below, so remember what linking would discard:
// every brain transform and the tools. Comparing the saved config instead would miss a
// non-static brain edit, which the resource cannot hold yet linking still strips.
@@ -628,9 +656,9 @@
size="sm"
/>
</label>
{#if providerSaveError}
{#if providerSaveError ?? legacyMemorySaveError}
<p class="text-xs text-red-600 dark:text-red-400">
{providerSaveError}
{providerSaveError ?? legacyMemorySaveError}
</p>
{/if}
</div>
@@ -638,7 +666,10 @@
<Button
variant="accent"
startIcon={{ icon: Save }}
disabled={!newPath || !!pathError || saving || !!providerSaveError}
disabled={!newPath ||
!!pathError ||
saving ||
!!(providerSaveError ?? legacyMemorySaveError)}
onclick={saveAsAgent}
>
Save agent
@@ -32,7 +32,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'
@@ -43,19 +43,32 @@
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 { AlertTriangle, Plus, X } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import { toolEnabledName, type AgentTool } from '../agentToolUtils'
import {
AGENT_FIELDS,
AGENT_FIELD_BY_KEY,
AGENT_HISTORY_KEYS,
AGENT_MEMORY_DOCS_URL,
AGENT_TOOLS_ROW,
AGENT_FIELD_GROUPS,
agentFieldAppliesTo,
agentMemoryMode,
historyInputApplies,
type AgentMemoryMode,
initialVisibleAgentFields,
type AgentFieldGroup,
type AgentFieldSpec
type AgentFieldSpec,
type AgentHistoryKey
} from '../agentFormFields'
import AgentToolRoster from './AgentToolRoster.svelte'
import AgentMemoryNotes from './AgentMemoryNotes.svelte'
import { memoryOptionLabel, memoryPropertyFor } from '../flowInfers'
interface Props {
schema: Schema | { properties?: Record<string, any> }
@@ -99,6 +112,9 @@
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 managed memory decides
* which history inputs the step offers. */
linkedMemory?: { memory: unknown } | undefined
}
let {
@@ -128,7 +144,8 @@
onSelectTool = undefined,
onAddTool = undefined,
onDeleteTool = undefined,
toolPickerPortal = undefined
toolPickerPortal = undefined,
linkedMemory = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
@@ -163,6 +180,32 @@
let schemaProperties = $derived((schema?.properties ?? {}) as Record<string, any>)
// Which memory shape the brain edited here, or the linked agent's, holds. Unknown for an
// expression or a linked agent that has not loaded, which keeps previous messages addable.
let memoryMode = $derived.by((): AgentMemoryMode | undefined => {
if ('memory' in schemaProperties) {
const transform = args?.memory
return transform == undefined || transform.type === 'static'
? agentMemoryMode(transform?.value)
: undefined
}
return linkedMemory ? agentMemoryMode(linkedMemory.memory) : 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, chatInputEnabled)
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)
}
// Offer the agent's own tools as the choices for `enabled_tools`, rather than asking for names
// to be typed. Written into the schema because that is where `InputTransformForm` reads a
// field's shape from; `flowInfers` hands every step its own copy, so this stays this step's.
@@ -190,6 +233,54 @@
)
)
// Offered when managed memory is on or an expression the form cannot read, not while a linked
// agent's setting is unknown.
// 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')
)
// Names the legacy value the way the memory field's own button does, reading it wherever the mode
// came from, so the row and the setting it points at cannot name it differently.
let legacyMemoryNote = $derived.by(() => {
const onThisForm = 'memory' in schemaProperties
const label = memoryOptionLabel(
onThisForm
? args?.memory?.type === 'static'
? args.memory.value
: undefined
: linkedMemory?.memory
)
return onThisForm
? `Ignored while memory is set to ${label}.`
: `Ignored while the agent's memory is set to ${label}.`
})
let memoryIdOffered = $derived(
(memoryMode === 'managed' || 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
@@ -229,21 +320,32 @@
})
})
// A history input's row follows its key rather than `visible`, so the run form is told about one
// only while the step holds the key: an inherited memory id has nothing a test run could inherit.
$effect(() => {
const keys = [...visible]
const keys = [
...[...visible].filter((key) => !isHistoryKey(key)),
...AGENT_HISTORY_KEYS.filter((key) => args?.[key] != undefined)
]
untrack(() => rememberOpenFields(visibilityKey, keys))
})
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) &&
// Memory id's row appears on its own when it is offered, so the menu never adds it.
spec.key !== 'memory_id' &&
!(isHistoryKey(spec.key) && !historyInputApplies(spec.key, memoryMode))
)
}
@@ -260,10 +362,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.
@@ -294,6 +400,92 @@
)
</script>
{#snippet unsetButton(spec: AgentFieldSpec)}
{#if !spec.core && !readOnly}
<Button
variant="subtle"
unifiedSize="2xs"
iconOnly
startIcon={{ icon: X }}
wrapperClasses="ml-1"
title="Unset {spec.label}"
on:click={() => removeField(spec)}
/>
{/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,
header: Snippet | undefined = undefined,
collapsed: boolean = false
)}
<InputTransformForm
{previousModuleId}
bind:arg={args[key]}
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] ?? {}}
bind:inputCheck={() => inputCheck[key] ?? false, (value) => (inputCheck[key] = value)}
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
{variableEditor}
{itemPicker}
bind:pickForField
{pickableProperties}
enableAi={fieldAiEnabled}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
noDynamicToggle={staticOnly}
noConnect={staticOnly || noConnect}
noJavascript={staticOnly || noJavascript}
s3StorageConfigured={s3Storage.current}
{chatInputEnabled}
{workspace}
otherArgs={Object.fromEntries(Object.entries(args ?? {}).filter(([other]) => other !== key))}
>
{#snippet labelExtra()}
{#if removable}
{@render unsetButton(removable)}
{/if}
{/snippet}
</InputTransformForm>
{/snippet}
{#snippet addFieldMenu()}
{@const candidates = addableIn()}
{#if candidates.length > 0}
@@ -359,7 +551,7 @@
<div class="flex flex-col gap-6">
{#each rows as spec (spec.key)}
<ResizeTransitionWrapper innerClass="w-full" vertical>
{#if spec.virtual}
{#if spec.key === AGENT_TOOLS_ROW}
<AgentToolRoster
{tools}
{onSelectTool}
@@ -372,60 +564,52 @@
`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}>
<InputTransformForm
{previousModuleId}
bind:arg={args[spec.key]}
bind:schema
argName={spec.key}
label={spec.label}
headerTooltip={spec.tooltip}
hideDescription
subtleControls
argExtra={schemaProperties[spec.key] ?? {}}
bind:inputCheck={
() => inputCheck[spec.key] ?? false,
(value) => (inputCheck[spec.key] = value)
}
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
{variableEditor}
{itemPicker}
bind:pickForField
{pickableProperties}
enableAi={fieldAiEnabled}
{helperScript}
{isAgentTool}
{allowedAiTransforms}
noDynamicToggle={staticOnly}
noConnect={staticOnly || noConnect}
noJavascript={staticOnly || noJavascript}
s3StorageConfigured={s3Storage.current}
{chatInputEnabled}
{workspace}
otherArgs={Object.fromEntries(
Object.entries(args ?? {}).filter(([key]) => key !== spec.key)
{#if spec.key === 'memory'}
{@render transformField(spec.key, spec.label, spec.tooltip, spec)}
<AgentMemoryNotes
bind:args
{chatInputEnabled}
historyOnStep={scopedFields.some((f) => f.key === 'previous_messages')}
s3StorageConfigured={s3Storage.current}
/>
{:else if spec.key === 'memory_id' && memoryIdOffered}
{@render transformField(
spec.key,
spec.label,
spec.tooltip,
undefined,
memoryIdHeader,
args?.memory_id == undefined
)}
>
{#snippet labelExtra()}
{#if !spec.core && !readOnly}
<Button
variant="subtle"
unifiedSize="2xs"
iconOnly
startIcon={{ icon: X }}
wrapperClasses="ml-1"
title="Unset {spec.label}"
on:click={() => removeField(spec)}
/>
{/if}
{/snippet}
</InputTransformForm>
{#if spec.key === 'enabled_tools' && noToolsEnabled}
<div
class="mt-1 flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400"
>
<AlertTriangle size={12} />
Nothing selected: the agent runs with no tools.
</div>
{#if args?.memory_id == undefined}
<p class="mt-1 text-xs text-secondary">
Uses the <code>memory_id</code> the run was started with: the conversation
id in chat mode, or the <code>memory_id</code> query parameter otherwise.
<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, memoryMode)}
<p class="mt-1 text-2xs text-hint">
{memoryMode === 'legacy'
? legacyMemoryNote
: `Ignored while managed memory is ${memoryMode === 'managed' ? 'on' : 'off'}.`}
</p>
{/if}
{#if spec.key === 'enabled_tools' && noToolsEnabled}
<div
class="mt-1 flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400"
>
<AlertTriangle size={12} />
Nothing selected: the agent runs with no tools.
</div>
{/if}
{/if}
</div>
{/if}
@@ -48,6 +48,7 @@
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
import { AGENT_HISTORY_KEYS, DEFAULT_AGENT_MEMORY } from '../agentFormFields'
import { nextId } from '../flowModuleNextId'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
@@ -557,7 +558,7 @@
const aiAgentModules = flowStore.val.value.modules.filter((m) => m.value.type === 'aiagent')
if (aiAgentModules.length === 0) {
// No AI agent exists, create one with context memory set to 10
// No AI agent exists, so create one reading the chat's user message
const aiAgentId = nextId(flowStateStore.val, flowStore.val)
flowStore.val.value.modules = [
...flowStore.val.value.modules,
@@ -571,8 +572,8 @@
if (key === 'user_message') {
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
} else if (key === 'memory') {
accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } }
} else {
accu[key] = { type: 'static', value: structuredClone(DEFAULT_AGENT_MEMORY) }
} else if (!(AGENT_HISTORY_KEYS as readonly string[]).includes(key)) {
accu[key] = {
type: 'static',
value: undefined
@@ -586,7 +587,7 @@
}
]
sendUserToast(
'Chat mode enabled. AI agent created with user message input and context memory set to 10.',
'Chat mode enabled. AI agent created with the user message as its input and managed memory on.',
false
)
} else if (aiAgentModules.length === 1) {
@@ -617,12 +618,20 @@
applied.push('user message input')
}
if (isUnconfigured(value.input_transforms['memory'])) {
// A linked step's memory belongs to the agent it links, and a step supplying its own
// previous messages reads them only while memory is off. An empty static list supplies none.
const messages = value.input_transforms['previous_messages']
if (
!value.agent &&
(isUnconfigured(messages) ||
(messages?.type === 'static' && !(messages.value as unknown[] | undefined)?.length)) &&
isUnconfigured(value.input_transforms['memory'])
) {
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
value: structuredClone(DEFAULT_AGENT_MEMORY)
}
applied.push('context memory set to 10')
applied.push('managed memory on')
}
sendUserToast(
@@ -291,6 +291,8 @@
}
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined =
$state(undefined)
// 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)
async function reload(flowModule: FlowModule) {
@@ -1154,6 +1156,8 @@
opWorkspace={opWs}
flowPath={$pathStore}
fromAgentEditor={agentEditorHost?.() != undefined}
bind:linkedMemory={linkedAgentMemory}
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
bind:agent={
() =>
flowModule.value.type === 'aiagent'
@@ -1235,6 +1239,7 @@
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
workspace={opWs}
visibilityKey={agentFieldsKey}
linkedMemory={agentLinked ? linkedAgentMemory : undefined}
tools={agentLinked
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
+127 -82
View File
@@ -4,6 +4,25 @@ import type { Schema } from '$lib/common'
import { emptySchema } from '$lib/utils'
import type { FlowModule, InputTransform } from '$lib/gen'
import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils'
import { AGENT_HISTORY_KEYS } from './agentFormFields'
/** Display names for the memory field's options, so anything else naming the setting an author
* picked cannot drift from the button they see. */
export const MEMORY_OPTION_LABELS: Record<string, string> = {
off: 'Off',
window: 'On',
auto: 'On (legacy)',
manual: 'Previous messages (legacy)'
}
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
}
export const AI_AGENT_SCHEMA: Schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -39,102 +58,80 @@ export const AI_AGENT_SCHEMA: Schema = {
},
memory: {
type: 'object',
description: 'History sent between the system message and the user message.',
description:
'Windmill stores the conversation and sends its last messages with each request.',
enumLabels: MEMORY_OPTION_LABELS,
oneOf: [
{
type: 'object',
title: 'off',
properties: {
kind: {
type: 'string',
enum: ['off'],
description: 'Disable conversation memory'
}
kind: { type: 'string', enum: ['off'] }
}
},
{
type: 'object',
title: 'auto',
title: 'window',
properties: {
kind: {
type: 'string',
enum: ['auto'],
default: 'auto',
description: 'Automatically manage conversation history'
},
kind: { type: 'string', enum: ['window'] },
context_length: {
type: 'number',
description:
'Number of most recent messages to store and load. Set to 0 to disable memory.',
default: 5
},
memory_id: {
type: 'string',
format: 'uuid',
'x-auto-generate': true,
description:
'Custom memory identifier. Each unique ID maintains separate conversation history.',
hideWhenChatEnabled: true
title: 'Messages to keep',
description: 'Number of most recent messages to load and store. 0 turns memory off.',
default: 10
}
},
required: ['kind'],
'x-no-s3-storage-workspace-warning':
'When no S3 storage is configured in your workspace settings, memory will be stored in database, which implies a limit of 100KB per memory entry. If you need to store more messages, you should use S3 storage in your workspace settings.'
},
{
type: 'object',
title: 'manual',
properties: {
kind: {
type: 'string',
enum: ['manual'],
description:
'Manually provide conversation messages, bypassing automatic memory management'
},
messages: {
type: 'array',
description: 'Array of conversation messages to use as history',
items: {
type: 'object',
properties: {
role: {
type: 'string',
enum: ['user', 'assistant', 'system']
},
content: {
type: 'string'
},
tool_calls: {
type: 'array',
nullable: true,
items: {
type: 'object',
properties: {
id: { type: 'string' },
type: { type: 'string' },
function: {
type: 'object',
properties: {
name: { type: 'string' },
arguments: { type: 'string' }
}
}
}
}
},
tool_call_id: {
type: 'string',
nullable: true,
description: 'The ID of the tool call this message is responding to'
required: ['kind', 'context_length']
}
],
showExpr: "fields.output_type !== 'image'"
},
memory_id: {
type: 'string',
description:
'Names the memory this step reads and writes, overriding the memory id the run was started with. Read only while managed memory is on, and not at all by an older auto or manual memory.',
showExpr: "fields.output_type !== 'image'"
},
previous_messages: {
type: 'array',
description:
'History the flow supplies, sent before the user message. Read only while managed memory is off, and not at all by an older auto or manual memory.',
items: {
type: 'object',
properties: {
role: {
type: 'string',
enum: ['user', 'assistant', 'system']
},
content: {
type: 'string'
},
tool_calls: {
type: 'array',
nullable: true,
items: {
type: 'object',
properties: {
id: { type: 'string' },
type: { type: 'string' },
function: {
type: 'object',
properties: {
name: { type: 'string' },
arguments: { type: 'string' }
}
},
required: ['role']
}
}
}
},
required: ['kind', 'messages']
}
],
tool_call_id: {
type: 'string',
nullable: true,
description: 'The ID of the tool call this message is responding to'
}
},
required: ['role']
},
showExpr: "fields.output_type !== 'image'"
},
output_schema: {
@@ -190,6 +187,8 @@ export const AI_AGENT_SCHEMA: Schema = {
'system_prompt',
'streaming',
'memory',
'memory_id',
'previous_messages',
'output_schema',
'user_attachments',
'enabled_tools',
@@ -199,6 +198,49 @@ 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. No field carries a
* default: the form writes one into a missing field on open, and a missing count runs as off. */
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' },
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?.previous_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. Otherwise the property itself is returned, which callers compare by identity to
* avoid rebuilding the step schema. */
export function memoryPropertyFor(property: any, value: any, chatInputEnabled = false): any {
let legacy = value?.kind ? LEGACY_MEMORY_VARIANTS[value.kind] : undefined
if (!legacy || !property?.oneOf) return property
// The form fills an empty string field with `''` when it opens, so the baked id field is only
// offered to a value saved with the key. Keyed on presence rather than content, or clearing the
// id to retype it would remove the field mid-edit. A chat flow runs on the conversation id and
// drops the baked one on save, so the field is not offered there; the nested form then removes
// the key from the value on open, as the hidden field did before.
if (value.kind === 'auto' && (chatInputEnabled || !('memory_id' in value))) {
const { memory_id: _, ...properties } = legacy.properties
legacy = { ...legacy, properties }
}
return { ...property, oneOf: [...property.oneOf, legacy] }
}
function migrateAiAgentInputTransforms(
inputTransforms: Record<string, InputTransform>
): Record<string, InputTransform> {
@@ -300,10 +342,13 @@ export async function loadSchemaFromModule(
: Object.keys(AI_AGENT_SCHEMA.properties ?? {})
return {
input_transforms: keys.reduce((accu, key) => {
accu[key] = input_transforms[key] ?? {
type: 'static',
value: undefined
}
const transform =
input_transforms[key] ??
// A present history input is the step's choice at runtime, so it gets no placeholder.
((AGENT_HISTORY_KEYS as readonly string[]).includes(key)
? undefined
: { type: 'static', value: undefined })
if (transform) accu[key] = transform
return accu
}, {}),
// A copy per step, never the shared constant: the form writes back into the property it
@@ -1,3 +1,4 @@
import { DEFAULT_AGENT_MEMORY } from './agentFormFields'
import type { Schema } from '$lib/common'
import {
ScriptService,
@@ -197,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: '' }
@@ -214,7 +216,16 @@ export async function createAiAgent(
...(agentPath
? {}
: {
provider: { type: 'static', value: providerValue }
provider: { type: 'static', value: providerValue },
// 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 }
}
@@ -491,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)
@@ -177,9 +177,10 @@ type AiAgentValue = Extract<FlowModule['value'], { type: 'aiagent' }>
* the step's own flow-local inputs kept on top.
*
* The overlay order is the worker's (`ai_executor.rs`): its linked branch interpolates the whole
* resource brain and only then writes `user_message`/`user_attachments` back from the step's own
* args. `tool_inputs` stays untouched the worker overlays it onto the tools in both branches, so
* an inlined step keeps the host flow's tool bindings.
* resource brain and only then writes the flow-local inputs (`user_message`, `user_attachments`,
* `enabled_tools`, `memory_id`, `previous_messages`) back from the step's own args. `tool_inputs`
* stays untouched the worker overlays it onto the tools in both branches, so an inlined step
* keeps the host flow's tool bindings.
*/
export function inlineAgentDraft(value: AiAgentValue, args: AIAgentConfig): AiAgentValue {
const { agent: _agent, ...rest } = value
@@ -6,7 +6,6 @@ import {
type Job,
type RestartedFrom,
type OpenFlow,
type MemoryConfig,
type FlowValue,
type Retry
} from '$lib/gen'
@@ -112,7 +111,6 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) {
}
import { dfs as dfsApply } from './dfs'
import { randomUUID } from '$lib/utils/uuid'
export function cleanFlow(flow: OpenFlow | any): OpenFlow & {
tag?: string
@@ -141,24 +139,8 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & {
if (mod.value.type == 'rawscript' && mod.value.assets?.length == 0) {
mod.value.assets = undefined
}
// Generate memory_id for AI agents with auto memory if not already set
// Only if chat input is not enabled, as otherwise memory id is based on conversation id
if (!newFlow.value.chat_input_enabled && mod.value.type === 'aiagent') {
const memoryTransform = mod.value.input_transforms?.memory
if (memoryTransform?.type === 'static' && memoryTransform.value) {
const memoryValue = memoryTransform.value as MemoryConfig
if (
memoryValue.kind === 'auto' &&
memoryValue.context_length &&
memoryValue.context_length > 0 &&
!memoryValue.memory_id
) {
memoryTransform.value = {
...memoryValue,
memory_id: randomUUID()
}
}
}
if (mod.value.type === 'aiagent') {
normalizeAgentHistory(mod.value.input_transforms, newFlow.value.chat_input_enabled ?? false)
}
})
if (newFlow.value.concurrency_key == '') {
@@ -168,6 +150,45 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & {
return newFlow
}
/**
* 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, and managed memory that keeps no messages
* runs as off, so it is saved as off.
*/
export function normalizeAgentHistory(
inputTransforms: Record<string, any> | undefined,
chatInputEnabled: boolean
) {
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' &&
'memory_id' in memory.value &&
(chatInputEnabled || !String(memory.value.memory_id ?? '').trim())
) {
const { memory_id: _, ...policy } = memory.value
memory.value = policy
}
const memoryId = inputTransforms.memory_id
if (memoryId?.type === 'static' && !String(memoryId.value ?? '').trim()) {
delete inputTransforms.memory_id
}
const previousMessages = inputTransforms.previous_messages
if (previousMessages?.type === 'static' && !previousMessages.value?.length) {
delete inputTransforms.previous_messages
}
}
export function getDefaultExpr(
key: string = 'myfield',
previousModuleId: string | undefined,
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { FlowValue } from '$lib/gen'
import { modulesWithRetryOrSleep } from './utils.svelte'
import { modulesWithRetryOrSleep, normalizeAgentHistory } from './utils.svelte'
const constantRetry = { constant: { attempts: 1, seconds: 5 } }
@@ -47,3 +47,53 @@ describe('modulesWithRetryOrSleep', () => {
expect(modulesWithRetryOrSleep(flow)).toEqual([])
})
})
describe('normalizeAgentHistory', () => {
const legacy = () => ({
memory: {
type: 'static',
value: { kind: 'auto', context_length: 10, memory_id: '0f5c3a8e-1d2b-4c6a-9e7f-3b8d2a1c4e6f' }
}
})
// Outside chat the baked id is still read for runs that pass none, and an older worker must keep
// accepting the step, so a save leaves it exactly as it was.
it('keeps a legacy baked memory id outside chat mode', () => {
const transforms = legacy()
normalizeAgentHistory(transforms, false)
expect(transforms).toEqual(legacy())
})
it('drops a legacy baked memory id in chat mode, where it was never read', () => {
const transforms = legacy()
normalizeAgentHistory(transforms, true)
expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 })
})
it('drops an empty baked memory id, which names no memory', () => {
const transforms = {
memory: { type: 'static', value: { kind: 'auto', context_length: 10, memory_id: '' } }
}
normalizeAgentHistory(transforms, false)
expect(transforms.memory.value).toEqual({ kind: 'auto', context_length: 10 })
})
it('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: ' ' },
previous_messages: { type: 'static', value: [] }
}
normalizeAgentHistory(transforms, false)
expect(transforms).toEqual({})
})
})
+46 -4
View File
@@ -533,9 +533,30 @@ components:
required:
- kind
MemoryWindow:
type: object
description: |
Keeps the most recent messages of the memory named by the run's memory id (or the step's
`memory_id`). Without a memory id the agent runs without memory.
properties:
kind:
type: string
enum:
- window
context_length:
type: integer
description: Number of most recent messages to load and store. 0 turns memory off.
required:
- kind
- context_length
MemoryAuto:
type: object
description: Automatic context management
deprecated: true
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. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.
properties:
kind:
type: string
@@ -568,7 +589,8 @@ components:
MemoryManual:
type: object
description: Explicit message history
deprecated: true
description: Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.
properties:
kind:
type: string
@@ -583,15 +605,17 @@ components:
- messages
MemoryConfig:
description: Conversation memory configuration
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 `previous_messages`.
oneOf:
- $ref: '#/components/schemas/MemoryOff'
- $ref: '#/components/schemas/MemoryWindow'
- $ref: '#/components/schemas/MemoryAuto'
- $ref: '#/components/schemas/MemoryManual'
discriminator:
propertyName: kind
mapping:
'off': '#/components/schemas/MemoryOff'
window: '#/components/schemas/MemoryWindow'
auto: '#/components/schemas/MemoryAuto'
manual: '#/components/schemas/MemoryManual'
@@ -1054,6 +1078,24 @@ components:
Streaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result
memory:
$ref: '#/components/schemas/MemoryTransform'
memory_id:
allOf:
- $ref: '#/components/schemas/InputTransform'
description: |
String. Names the memory this step reads and writes, overriding the memory id the run
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. Read only
while `memory` is `window`: it is ignored when memory is off, and an older `auto` or
`manual` memory reads neither history input.
previous_messages:
allOf:
- $ref: '#/components/schemas/InputTransform'
description: |
Array of MemoryMessage. History supplied by the flow, sent between the system prompt
and the user message. Read only while `memory` is off or absent: managed memory
ignores it, and an older `auto` or `manual` memory reads neither history input.
output_schema:
allOf:
- $ref: '#/components/schemas/InputTransform'
@@ -1127,7 +1169,7 @@ components:
Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain
config (provider/model/system prompt/etc.) and tool set are resolved at runtime from
that resource; the module's input_transforms then only carry the flow-local inputs
(user_message/user_attachments/enabled_tools).
(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).
tool_inputs:
type: object
description: |
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