Files
windmill/backend/windmill-worker/src/ai/utils.rs
T
hugocasaandClaude Opus 5 c297ed0052 feat: managed memory with an inherited or custom memory id per step (#11118)
* feat: split ai agent memory into agent policy, run memory id and step history

* fix: scope string memory ids to workspace and flow, keep nested tool history inputs

* chore: update sqlx cache for the flow context query

* docs: describe memory id scoping as collision-free rather than isolated

* chore: regenerate openflow json after merging main

* fix: offer no memory id for legacy manual memory, document linked history inputs

* fix: seed provided messages from legacy manual memory and hide its note once set

* fix: bypass memory when a provided messages expression evaluates to null

* fix: require a user message when provided messages are empty

* chore: keep the empty messages comment within the line width

* docs: name the history inputs wherever linked steps list their flow-local inputs

* docs: keep the memory storage path on one line

* feat: managed memory with an inherited or custom memory id per step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: list a custom memory id in the test run form and name where an inherited one comes from

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep memory id out of the add-field menu and drop the memory id telemetry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep legacy auto memory without an id working after an untouched redeploy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: rename step messages to previous_messages and address review

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: rewrap comments and docs lines lengthened by the previous_messages rename

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: read agent memory as either a legacy shape or the current one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: name the memory setting in ignored-input notes and keep conversions honest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep a legacy memory count unset on open and read a cleared count as off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: drop flow-local keys from a linked agent resource before interpolating it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a linked resource's own inputs as fallbacks and note ignored history on image runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: restore the linked agent draft tests and log ignored history on every image run

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: resolve the one-of variant from the value when the selected one leaves the list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: treat zero-count managed memory as off when enabling chat mode and shorten comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: stop requiring user_message in the openflow agent contract when previous messages are the prompt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:43 +02:00

734 lines
23 KiB
Rust

use anyhow::Context;
use serde_json::value::RawValue;
use sqlx::types::Json;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use uuid::Uuid;
use windmill_ai::types::*;
#[cfg(feature = "mcp")]
use windmill_common::client::AuthedClient;
use windmill_common::flows::FlowModuleValue;
use windmill_common::{
db::DB,
error::Error,
flow_conversations::{add_message_to_conversation_tx, MessageType},
flow_status::AgentAction,
flows::{InputTransform, Step},
jobs::JobKind,
scripts::{ScriptHash, ScriptLang},
worker::to_raw_value,
};
#[cfg(feature = "mcp")]
use windmill_mcp::{McpClient, McpResource, McpTool};
use windmill_queue::{flow_status::get_step_of_flow_status, MiniPulledJob};
use crate::parse_sig_of_lang;
pub async fn parse_raw_script_schema(
content: &str,
language: &ScriptLang,
) -> Result<Box<RawValue>, Error> {
let main_arg_signature = parse_sig_of_lang(content, Some(&language), None).await?
.ok_or_else(|| Error::BadConfig(format!(
"Cannot parse signature for language {:?}. The language parser may not be enabled in this build.",
language
)))?;
let schema = OpenAPISchema {
r#type: Some(SchemaType::default()),
properties: Some(
main_arg_signature
.args
.iter()
.map(|arg| {
let name = arg.name.clone();
let typ = OpenAPISchema::from_typ(&arg.typ);
(name, Box::new(typ))
})
.collect(),
),
required: Some(
main_arg_signature
.args
.iter()
.map(|arg| arg.name.clone())
.collect(),
),
..Default::default()
};
Ok(to_raw_value(&schema))
}
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
/// Filters out properties from a JSON schema that have completed input transforms.
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
pub fn filter_schema_by_input_transforms(
schema: Box<RawValue>,
input_transforms: &HashMap<String, InputTransform>,
) -> Result<Box<RawValue>, Error> {
// Parse the schema JSON
let mut schema_value: serde_json::Value = serde_json::from_str(schema.get())
.context("Failed to parse schema JSON")
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
// Collect keys to remove (parameters with completed input transforms)
let keys_to_remove: HashSet<String> = input_transforms
.iter()
.filter_map(|(key, transform)| {
let is_completed = is_completed_input_transform(transform);
if is_completed {
Some(key.clone())
} else {
None
}
})
.collect();
if !keys_to_remove.is_empty() {
// Remove completed parameters from properties
if let Some(properties) = schema_value
.get_mut("properties")
.and_then(|p| p.as_object_mut())
{
for key in &keys_to_remove {
properties.remove(key);
}
}
// Also remove from required array
if let Some(required) = schema_value
.get_mut("required")
.and_then(|r| r.as_array_mut())
{
required.retain(|item| {
if let Some(key) = item.as_str() {
!keys_to_remove.contains(key)
} else {
true
}
});
}
}
// Convert back to RawValue
Ok(to_raw_value(&schema_value))
}
#[derive(Clone)]
pub struct FlowJobRunnableIdAndRawFlow {
pub runnable_id: Option<ScriptHash>,
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
pub kind: JobKind,
pub parent_job: Option<Uuid>,
pub flow_step_id: Option<String>,
}
pub async fn get_flow_job_runnable_and_raw_flow(
db: &DB,
job_id: &uuid::Uuid,
) -> windmill_common::error::Result<FlowJobRunnableIdAndRawFlow> {
let job = sqlx::query_as!(
FlowJobRunnableIdAndRawFlow,
"SELECT runnable_id as \"runnable_id: ScriptHash\", raw_flow as \"raw_flow: _\", kind as \"kind: _\", parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_one(db)
.await?;
Ok(job)
}
#[derive(Debug, Clone, Default)]
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
pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
let root_job_id = job
.root_job
.or(job.flow_innermost_root_job)
.or(job.parent_job);
let Some(root_job_id) = root_job_id else {
return FlowContext::default();
};
match sqlx::query!(
r#"
SELECT
j.args as "args: Json<HashMap<String, Box<RawValue>>>",
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
"#,
root_job_id
)
.fetch_optional(db)
.await
{
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!(
"No flow context found for root job {} (agent job {}), returning default",
root_job_id,
job.id
);
FlowContext::default()
}
Err(e) => {
tracing::error!("Failed to get flow context for job {}: {}", job.id, e);
FlowContext::default()
}
}
}
// Add message to conversation
pub async fn add_message_to_conversation(
db: &DB,
conversation_id: &Uuid,
job_id: Option<Uuid>,
message_content: &str,
message_type: MessageType,
step_name: &Option<String>,
success: bool,
) -> Result<(), Error> {
let mut tx = db.begin().await?;
add_message_to_conversation_tx(
&mut tx,
*conversation_id,
job_id,
&message_content,
message_type,
step_name.as_deref(),
success,
)
.await?;
tx.commit().await?;
Ok(())
}
/// Find a unique tool name for structured output tool to avoid collisions with user-provided tools
pub fn find_unique_tool_name(base_name: &str, existing_tools: Option<&[ToolDef]>) -> String {
let Some(tools) = existing_tools else {
return base_name.to_string();
};
if !tools.iter().any(|t| t.function.name == base_name) {
return base_name.to_string();
}
for i in 1..100 {
let candidate = format!("{}_{}", base_name, i);
if !tools.iter().any(|t| t.function.name == candidate) {
return candidate;
}
}
// Fallback with process id if somehow we can't find a unique name
format!("{}_{}_fallback", base_name, std::process::id())
}
pub async fn update_flow_status_module_with_actions(
db: &DB,
parent_job: &Uuid,
actions: &[AgentAction],
) -> Result<(), Error> {
let step = get_step_of_flow_status(db, parent_job.to_owned()).await?;
match step {
Step::Step { idx: step, .. } => {
sqlx::query!(
r#"
UPDATE v2_job_status SET
flow_status = jsonb_set(
flow_status,
array['modules', $3::TEXT, 'agent_actions'],
$2
)
WHERE id = $1
"#,
parent_job,
sqlx::types::Json(actions) as _,
step as i32
)
.execute(db)
.await?;
}
_ => {}
}
Ok(())
}
pub async fn update_flow_status_module_with_actions_success(
db: &DB,
parent_job: &Uuid,
action_success: bool,
) -> Result<(), Error> {
let step = get_step_of_flow_status(db, parent_job.to_owned()).await?;
match step {
Step::Step { idx: step, .. } => {
// Append the new bool to the existing array, or create a new array if it doesn't exist
sqlx::query!(
r#"
UPDATE v2_job_status SET
flow_status = jsonb_set(
flow_status,
array['modules', $2::TEXT, 'agent_actions_success'],
COALESCE(
flow_status->'modules'->$2->'agent_actions_success',
to_jsonb(ARRAY[]::bool[])
) || to_jsonb(ARRAY[$3::bool])
)
WHERE id = $1
"#,
parent_job,
step as i32,
action_success
)
.execute(db)
.await?;
}
_ => {}
}
Ok(())
}
/// Get step name from the flow module (summary if exists, else id)
pub fn get_step_name_from_flow(
summary: Option<&str>,
flow_step_id: Option<&str>,
) -> Option<String> {
let flow_step_id = flow_step_id?;
Some(
summary
.map(|s| s.to_string())
.unwrap_or_else(|| format!("AI Agent Step {}", flow_step_id)),
)
}
/// Cleanup MCP clients by gracefully shutting down connections
#[cfg(feature = "mcp")]
pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
if mcp_clients.is_empty() {
return;
}
tracing::debug!("Cleaning up {} MCP client(s)", mcp_clients.len());
for (resource_name, client) in mcp_clients {
// Try to unwrap the Arc to get the McpClient
match Arc::try_unwrap(client) {
Ok(client) => {
tracing::debug!("Shutting down MCP client for {}", resource_name);
if let Err(e) = client.shutdown().await {
tracing::warn!("Failed to shutdown MCP client for {}: {}", resource_name, e);
}
}
Err(arc) => {
// Other references still exist (shouldn't happen in normal flow)
tracing::warn!(
"MCP client for {} still has {} references, dropping without graceful shutdown",
resource_name,
Arc::strong_count(&arc)
);
}
}
}
}
#[cfg(feature = "mcp")]
fn sanitize_tool_name_part(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// Convert raw MCP tools to Windmill Tool format with source tracking
#[cfg(feature = "mcp")]
fn convert_mcp_tools_to_windmill_tools(
mcp_tools: &[McpTool],
resource_name: &str,
resource_path: &str,
) -> Result<Vec<Tool>, Error> {
mcp_tools
.iter()
.map(|mcp_tool| {
let sanitized_resource_name = sanitize_tool_name_part(resource_name);
let tool_name = format!("mcp_{}_{}", sanitized_resource_name, mcp_tool.name);
let mut schema_value = serde_json::to_value(&*mcp_tool.input_schema)
.context("Failed to convert MCP schema to JSON value")?;
McpClient::fix_array_schemas(&mut schema_value);
let parameters = to_raw_value(&schema_value);
// Build the description from title and description
let description = if let Some(title) = &mcp_tool.title {
if let Some(desc) = &mcp_tool.description {
Some(format!("{}: {}", title, desc))
} else {
Some(title.to_string())
}
} else {
mcp_tool.description.as_ref().map(|d| d.to_string())
};
let tool_def_function =
ToolDefFunction { name: tool_name.clone(), description, parameters };
let tool_def = ToolDef { r#type: "function".to_string(), function: tool_def_function };
Ok(Tool {
def: tool_def,
module: None,
mcp_source: Some(McpToolSource {
name: resource_name.to_string(),
tool_name: mcp_tool.name.to_string(),
resource_path: resource_path.to_string(),
}),
})
})
.collect()
}
/// Configuration for loading tools from an MCP server resource
#[cfg(feature = "mcp")]
#[derive(Debug, Clone)]
pub struct McpResourceConfig {
pub resource_path: String,
pub include_tools: Option<Vec<String>>,
pub exclude_tools: Option<Vec<String>>,
}
/// Apply include/exclude filters to a list of tools
/// Priority: include_tools > exclude_tools > all
/// - If include_tools is Some and non-empty: whitelist approach (keep only listed tools)
/// - Else if exclude_tools is Some and non-empty: blacklist approach (remove listed tools)
/// - Otherwise: no filtering (keep all tools)
#[cfg(feature = "mcp")]
fn apply_tool_filters(
tools: Vec<Tool>,
include_tools: &Option<Vec<String>>,
exclude_tools: &Option<Vec<String>>,
) -> Vec<Tool> {
// If include_tools is specified and non-empty, use whitelist approach
if let Some(include_list) = include_tools {
if !include_list.is_empty() {
return tools
.into_iter()
.filter(|tool| {
tool.mcp_source
.as_ref()
.map(|src| include_list.contains(&src.tool_name))
.unwrap_or(false)
})
.collect();
}
}
// If exclude_tools is specified and non-empty, use blacklist approach
if let Some(exclude_list) = exclude_tools {
if !exclude_list.is_empty() {
return tools
.into_iter()
.filter(|tool| {
tool.mcp_source
.as_ref()
.map(|src| !exclude_list.contains(&src.tool_name))
.unwrap_or(true)
})
.collect();
}
}
// No filtering - return all tools
tools
}
/// Check if a token variable is expired and refresh it if needed via API call
#[cfg(feature = "mcp")]
async fn refresh_token_if_expired(
db: &DB,
workspace_id: &str,
token_path: &str,
auth_token: &str,
) -> Result<(), Error> {
// Query variable with account join to check expiration
let token_info = sqlx::query!(
r#"
SELECT
variable.path,
variable.account as account_id,
(now() > account.expires_at) as "is_expired: bool"
FROM variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
WHERE variable.path = $1 AND variable.workspace_id = $2
"#,
token_path,
workspace_id
)
.fetch_optional(db)
.await?;
let Some(token_info) = token_info else {
return Ok(());
};
let Some(account_id) = token_info.account_id else {
return Ok(());
};
if !token_info.is_expired.unwrap_or(false) {
return Ok(());
}
tracing::debug!(
"Token variable {} is expired, triggering refresh",
token_path
);
// Call the API refresh endpoint
let base_url = (**windmill_common::BASE_URL.load()).clone();
let refresh_url = format!(
"{}/api/w/{}/oauth/refresh_token/{}",
base_url, workspace_id, account_id
);
#[derive(serde::Serialize)]
struct RefreshRequest {
path: String,
}
let response = windmill_common::utils::HTTP_CLIENT
.post(&refresh_url)
.header("Authorization", format!("Bearer {}", auth_token))
.json(&RefreshRequest { path: token_path.to_string() })
.send()
.await
.map_err(|e| {
Error::internal_err(format!("Failed to call token refresh endpoint: {}", e))
})?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::internal_err(format!(
"Token refresh failed: {}",
error_text
)));
}
Ok(())
}
/// Load tools from MCP servers and return both the clients and tools
/// Returns a map of resource name -> client, and a vector of tools
#[cfg(feature = "mcp")]
pub async fn load_mcp_tools(
db: &DB,
workspace_id: &str,
mcp_configs: Vec<McpResourceConfig>,
client: &AuthedClient,
) -> Result<(HashMap<String, Arc<McpClient>>, Vec<Tool>), Error> {
let mut all_mcp_tools = Vec::new();
let mut mcp_clients = HashMap::new();
for config in mcp_configs {
tracing::debug!("Loading MCP tools from resource: {}", config.resource_path);
let path = config.resource_path.trim_start_matches("$res:");
// Load the resource through the job's permissioned (RLS + scope) path so
// a flow author cannot make the agent use an MCP resource their identity
// is not allowed to read (resources:read:{path}). Reading through the raw
// db pool here would bypass the authorization enforced by the regular MCP
// tools API (get_mcp_tools) and act as a confused deputy.
let mcp_resource = client
.get_resource_value::<McpResource>(path)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to load MCP resource {}: {}",
config.resource_path, e
))
})?;
let resource_name = mcp_resource.name.clone();
// Resolve the token through the job's permissioned (RLS + audit) path so
// the AI agent cannot exfiltrate a secret its identity is not allowed to
// read by pointing an MCP resource's token at it.
let token = if let Some(ref token_path) = mcp_resource.token {
let token_var_path = token_path.trim_start_matches("$var:");
if token_var_path.trim().is_empty() {
None
} else {
// Refresh first (best-effort) so the value we read is current.
if let Err(e) =
refresh_token_if_expired(db, workspace_id, token_var_path, &client.token).await
{
tracing::warn!(
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
resource_name, e
);
}
Some(
client
.get_variable_value(token_var_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to resolve token variable {} for MCP resource {}: {}",
token_var_path, resource_name, e
))
})?,
)
}
} else {
None
};
// Create new MCP client for this execution
tracing::debug!("Creating fresh MCP client for {}", resource_name);
let mcp_conn = McpClient::from_resource(mcp_resource, token)
.await
.context("Failed to create MCP client")?;
// Get raw MCP tools from client
let raw_mcp_tools = mcp_conn.available_tools();
// Convert to Windmill Tool format
let converted_tools =
convert_mcp_tools_to_windmill_tools(raw_mcp_tools, &resource_name, &path)?;
// Apply include/exclude filters
let filtered_tools = apply_tool_filters(
converted_tools,
&config.include_tools,
&config.exclude_tools,
);
tracing::info!(
"Loaded {} tools from MCP server '{}' (filtered from {} available tools)",
filtered_tools.len(),
resource_name,
raw_mcp_tools.len()
);
all_mcp_tools.extend(filtered_tools);
// Store client for later use and cleanup
let mcp_client = Arc::new(mcp_conn);
mcp_clients.insert(resource_name, mcp_client);
}
Ok((mcp_clients, all_mcp_tools))
}
/// Execute an MCP tool by routing the call to the appropriate MCP client
#[cfg(feature = "mcp")]
pub async fn execute_mcp_tool(
mcp_clients: &HashMap<String, Arc<McpClient>>,
mcp_source: &McpToolSource,
arguments_str: &str,
) -> Result<serde_json::Value, Error> {
// Get the MCP client from the provided map
let mcp_client = mcp_clients.get(&mcp_source.name).ok_or_else(|| {
Error::internal_err(format!(
"MCP client not found for resource: {}",
mcp_source.name
))
})?;
// Call the MCP tool
let result = mcp_client
.call_tool(&mcp_source.tool_name, arguments_str)
.await
.context("MCP tool call failed")?;
Ok(result)
}
// Stub implementations when mcp feature is not enabled
#[cfg(not(feature = "mcp"))]
pub struct McpResourceConfig {}
/// Stub for cleanup_mcp_clients when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn cleanup_mcp_clients<T>(_mcp_clients: HashMap<String, Arc<T>>) {
// No-op when MCP is disabled
}
/// Stub for load_mcp_tools when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn load_mcp_tools<T>(
_db: &DB,
_workspace_id: &str,
_mcp_configs: Vec<McpResourceConfig>,
_client: &windmill_common::client::AuthedClient,
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
Ok((HashMap::new(), Vec::new()))
}
/// Stub for execute_mcp_tool when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn execute_mcp_tool<T>(
_mcp_clients: &HashMap<String, Arc<T>>,
mcp_source: &McpToolSource,
_arguments_str: &str,
) -> Result<serde_json::Value, Error> {
Err(Error::internal_err(format!(
"MCP support is not enabled. Cannot execute MCP tool: {}",
mcp_source.tool_name
)))
}
/// Check if any tool's input transforms reference previous_result
pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
tools.iter().any(|tool| {
if let Some(module) = &tool.module {
if let Ok(module_value) = module.get_value() {
let input_transforms = match module_value {
FlowModuleValue::Script { input_transforms, .. } => input_transforms,
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
_ => return false,
};
return input_transforms.iter().any(|(_, transform)| {
if let windmill_common::flows::InputTransform::Javascript { expr } = transform {
expr.contains("previous_result")
} else {
false
}
});
}
}
false
})
}