fix: clean ai memory and cache bedrock prompts (#8847)

* fix: avoid persisting system prompts in ai memory

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

* fix: keep ai memory cleanup write-side only

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

* feat: add bedrock prompt caching for claude

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

* test: add bedrock memory regression

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

* fix: gate bedrock prompt caching by model id

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

* docs: link bedrock caching allowlist source

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-04-16 14:30:22 +00:00
committed by GitHub
co-authored by Claude Opus 4.5
parent fc49a8fed6
commit b1778272fc
5 changed files with 447 additions and 29 deletions
+16 -3
View File
@@ -131,6 +131,7 @@ async fn create_bedrock_client(
fn build_tool_config_from_request(
tools: Option<&[OpenAIToolDef]>,
tool_choice: Option<&serde_json::Value>,
enable_prompt_caching: bool,
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>> {
if let Some(tools) = tools {
let tool_defs: Vec<ToolDef> = tools
@@ -163,7 +164,7 @@ fn build_tool_config_from_request(
.map(|tc| tc == "required" || tc.as_str() == Some("required"))
.unwrap_or(false);
build_tool_config(Some(&tool_defs), force_tool_use)
build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching)
} else {
Ok(None)
}
@@ -365,8 +366,13 @@ pub async fn handle_bedrock_sdk_streaming(
.await?;
// Convert messages using shared conversion
let enable_prompt_caching =
windmill_common::ai_bedrock::bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
windmill_common::ai_bedrock::openai_messages_to_bedrock(
&openai_req.messages,
enable_prompt_caching,
)?;
// Build inference configuration
let inference_config = windmill_common::ai_bedrock::create_inference_config(
@@ -378,6 +384,7 @@ pub async fn handle_bedrock_sdk_streaming(
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
// Build the SDK request
@@ -625,8 +632,13 @@ pub async fn handle_bedrock_sdk_non_streaming(
.await?;
// Convert messages using shared conversion
let enable_prompt_caching =
windmill_common::ai_bedrock::bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
windmill_common::ai_bedrock::openai_messages_to_bedrock(
&openai_req.messages,
enable_prompt_caching,
)?;
// Build inference configuration
let inference_config = windmill_common::ai_bedrock::create_inference_config(
@@ -638,6 +650,7 @@ pub async fn handle_bedrock_sdk_non_streaming(
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
// Build the SDK request (non-streaming)
+202 -1
View File
@@ -94,6 +94,86 @@ pub async fn check_env_credentials() -> BedrockCredentialsCheck {
/// Constants for commonly used strings to avoid allocations
pub const FUNCTION_TYPE: &str = "function";
// AWS documents Bedrock prompt-caching support as a model allowlist rather than a
// capability exposed by the model metadata APIs:
// https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS: &[&str] = &[
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
];
fn build_default_cache_point() -> aws_sdk_bedrockruntime::types::CachePointBlock {
aws_sdk_bedrockruntime::types::CachePointBlock::builder()
.r#type(aws_sdk_bedrockruntime::types::CachePointType::Default)
.build()
.expect("cache point type is required")
}
fn normalize_bedrock_model_id(model: &str) -> String {
let model = model
.rsplit('/')
.next()
.unwrap_or(model)
.to_ascii_lowercase();
for prefix in ["global.", "us.", "eu.", "apac."] {
if let Some(normalized_model) = model.strip_prefix(prefix) {
return normalized_model.to_string();
}
}
model
}
pub fn bedrock_model_supports_prompt_caching(model: &str) -> bool {
let normalized_model = normalize_bedrock_model_id(model);
BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS.contains(&normalized_model.as_str())
}
fn append_cache_point_to_system_prompts(system_prompts: &mut Vec<SystemContentBlock>) {
if system_prompts.is_empty()
|| matches!(
system_prompts.last(),
Some(SystemContentBlock::CachePoint(_))
)
{
return;
}
system_prompts.push(SystemContentBlock::CachePoint(build_default_cache_point()));
}
fn append_cache_point_to_last_message(messages: &mut [Message]) -> Result<(), Error> {
let Some(last_message) = messages.last_mut() else {
return Ok(());
};
if matches!(
last_message.content().last(),
Some(ContentBlock::CachePoint(_))
) {
return Ok(());
}
let mut content = last_message.content().to_vec();
content.push(ContentBlock::CachePoint(build_default_cache_point()));
*last_message = Message::builder()
.role(last_message.role().clone())
.set_content(Some(content))
.build()
.map_err(|e| Error::internal_err(format!("Failed to append cache point: {}", e)))?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct BearerTokenProvider {
token: String,
@@ -277,6 +357,7 @@ pub fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document
/// Tuple of (conversation_messages, system_prompts)
pub fn openai_messages_to_bedrock(
messages: &[OpenAIMessage],
enable_prompt_caching: bool,
) -> Result<(Vec<Message>, Vec<SystemContentBlock>), Error> {
let mut bedrock_messages = Vec::new();
let mut system_prompts = Vec::new();
@@ -334,6 +415,11 @@ pub fn openai_messages_to_bedrock(
bedrock_messages.push(tool_result_message);
}
if enable_prompt_caching {
append_cache_point_to_system_prompts(&mut system_prompts);
append_cache_point_to_last_message(&mut bedrock_messages)?;
}
Ok((bedrock_messages, system_prompts))
}
@@ -703,9 +789,15 @@ pub fn streaming_tool_calls_to_openai(tool_calls: Vec<StreamingToolCall>) -> Vec
pub fn build_tool_config(
tools: Option<&[ToolDef]>,
force_tool_use: bool,
enable_prompt_caching: bool,
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>, Error> {
if let Some(tools) = tools {
let bedrock_tools = openai_tools_to_bedrock(tools)?;
let mut bedrock_tools = openai_tools_to_bedrock(tools)?;
if enable_prompt_caching && !matches!(bedrock_tools.last(), Some(Tool::CachePoint(_))) {
bedrock_tools.push(Tool::CachePoint(build_default_cache_point()));
}
let mut tool_config_builder = aws_sdk_bedrockruntime::types::ToolConfiguration::builder()
.set_tools(Some(bedrock_tools));
@@ -724,3 +816,112 @@ pub fn build_tool_config(
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::value::RawValue;
fn text_message(role: &str, content: &str) -> OpenAIMessage {
OpenAIMessage {
role: role.to_string(),
content: Some(OpenAIContent::Text(content.to_string())),
..Default::default()
}
}
fn test_tool() -> ToolDef {
ToolDef {
r#type: FUNCTION_TYPE.to_string(),
function: crate::ai_types::ToolDefFunction {
name: "test_tool".to_string(),
description: Some("A test tool".to_string()),
parameters: RawValue::from_string(
r#"{"type":"object","properties":{},"additionalProperties":false}"#.to_string(),
)
.expect("valid raw json"),
},
}
}
#[test]
fn openai_messages_to_bedrock_adds_cache_points_when_enabled() {
let messages = vec![
text_message("system", "Reply concisely"),
text_message("user", "Tell me a joke"),
];
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&messages, true).expect("bedrock conversion succeeds");
assert!(matches!(
system_prompts.last(),
Some(SystemContentBlock::CachePoint(_))
));
assert!(matches!(
bedrock_messages
.last()
.and_then(|message| message.content().last()),
Some(ContentBlock::CachePoint(_))
));
}
#[test]
fn openai_messages_to_bedrock_skips_cache_points_when_disabled() {
let messages = vec![
text_message("system", "Reply concisely"),
text_message("user", "Tell me a joke"),
];
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&messages, false).expect("bedrock conversion succeeds");
assert!(!matches!(
system_prompts.last(),
Some(SystemContentBlock::CachePoint(_))
));
assert!(!matches!(
bedrock_messages
.last()
.and_then(|message| message.content().last()),
Some(ContentBlock::CachePoint(_))
));
}
#[test]
fn build_tool_config_adds_cache_point_when_enabled() {
let tools = vec![test_tool()];
let tool_config =
build_tool_config(Some(&tools), false, true).expect("tool config succeeds");
assert!(matches!(
tool_config
.as_ref()
.and_then(|config| config.tools().last()),
Some(Tool::CachePoint(_))
));
}
#[test]
fn bedrock_prompt_caching_supports_documented_claude_model_ids() {
assert!(bedrock_model_supports_prompt_caching(
"anthropic.claude-haiku-4-5-20251001-v1:0"
));
assert!(bedrock_model_supports_prompt_caching(
"global.anthropic.claude-haiku-4-5-20251001-v1:0"
));
assert!(bedrock_model_supports_prompt_caching(
"arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
));
}
#[test]
fn bedrock_prompt_caching_rejects_unsupported_or_opaque_model_ids() {
assert!(!bedrock_model_supports_prompt_caching(
"anthropic.claude-3-haiku-20240307-v1:0"
));
assert!(!bedrock_model_supports_prompt_caching(
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile"
));
}
}
@@ -18,10 +18,11 @@ use windmill_common::{client::AuthedClient, error::Error};
// Re-export from shared module for use by other parts of the worker
use windmill_common::ai_bedrock::{
bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text,
bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, build_tool_config,
create_inference_config, format_bedrock_error, openai_messages_to_bedrock,
streaming_tool_calls_to_openai, StreamingToolCall,
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
StreamingToolCall,
};
pub use windmill_common::ai_bedrock::{check_env_credentials, BedrockClient};
@@ -71,13 +72,19 @@ impl BedrockQueryBuilder {
let prepared_messages = prepare_messages_for_api(messages, client, workspace_id).await?;
// Convert messages to Bedrock format (separates system prompts)
let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&prepared_messages)?;
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&prepared_messages, enable_prompt_caching)?;
// Build inference configuration using shared helper
let inference_config = create_inference_config(temperature, max_tokens.map(|t| t as i32));
// Build tool configuration with optional ToolChoice
let tool_config = build_tool_config(tools, structured_output_tool_name.is_some())?;
let tool_config = build_tool_config(
tools,
structured_output_tool_name.is_some(),
enable_prompt_caching,
)?;
self.execute_converse_stream(
&bedrock_client,
+121 -18
View File
@@ -92,6 +92,38 @@ lazy_static::lazy_static! {
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec<OpenAIMessage> {
messages
.iter()
.filter(|message| message.role != "system")
.cloned()
.collect()
}
fn strip_leading_tool_messages(messages: Vec<OpenAIMessage>) -> Vec<OpenAIMessage> {
match messages.iter().position(|message| message.role != "tool") {
Some(first_non_tool_index) => messages.into_iter().skip(first_non_tool_index).collect(),
None => Vec::new(),
}
}
fn prepare_auto_memory_messages_for_request(
loaded_messages: &[OpenAIMessage],
context_length: usize,
) -> Vec<OpenAIMessage> {
let start_idx = loaded_messages.len().saturating_sub(context_length);
strip_leading_tool_messages(loaded_messages[start_idx..].to_vec())
}
fn prepare_auto_memory_messages_for_persistence(
all_messages: &[OpenAIMessage],
context_length: usize,
) -> Vec<OpenAIMessage> {
let non_system_messages = strip_system_messages(all_messages);
let start_idx = non_system_messages.len().saturating_sub(context_length);
non_system_messages[start_idx..].to_vec()
}
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
@@ -654,18 +686,10 @@ pub async fn run_agent(
// Read messages from memory
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
// Take the last n messages
let start_idx =
loaded_messages.len().saturating_sub(*context_length);
let mut messages_to_load = loaded_messages[start_idx..].to_vec();
let first_non_tool_message_index =
messages_to_load.iter().position(|m| m.role != "tool");
// Remove the first messages if their role is "tool" to avoid OpenAI API error
if let Some(index) = first_non_tool_message_index {
messages_to_load = messages_to_load[index..].to_vec();
}
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
*context_length,
);
messages.extend(messages_to_load);
}
Ok(None) => {}
@@ -729,9 +753,7 @@ pub async fn run_agent(
.unwrap_or(false);
if has_message && has_attachments {
let mut parts = vec![ContentPart::Text {
text: args.user_message.clone().unwrap(),
}];
let mut parts = vec![ContentPart::Text { text: args.user_message.clone().unwrap() }];
for attachment in args.user_attachments.as_ref().unwrap() {
if !attachment.s3.is_empty() {
parts.push(ContentPart::S3Object { s3_object: attachment.clone() });
@@ -1306,9 +1328,10 @@ pub async fn run_agent(
final_messages.iter().map(|m| m.message.clone()).collect();
if !all_messages.is_empty() {
// Keep only the last n messages
let start_idx = all_messages.len().saturating_sub(*context_length);
let messages_to_persist = all_messages[start_idx..].to_vec();
let messages_to_persist = prepare_auto_memory_messages_for_persistence(
&all_messages,
*context_length,
);
if let Some(memory_id) = memory_id {
if let Err(e) = write_to_memory(
@@ -1349,6 +1372,86 @@ pub async fn run_agent(
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn text_message(role: &str, content: &str) -> OpenAIMessage {
OpenAIMessage {
role: role.to_string(),
content: Some(OpenAIContent::Text(content.to_string())),
..Default::default()
}
}
#[test]
fn auto_memory_request_preserves_messages_within_context_window() {
let loaded_messages = vec![
text_message("system", "instructions-a"),
text_message("user", "first-user"),
text_message("assistant", "first-assistant"),
text_message("system", "instructions-b"),
text_message("user", "second-user"),
text_message("assistant", "second-assistant"),
];
let prepared = prepare_auto_memory_messages_for_request(&loaded_messages, 3);
let roles: Vec<&str> = prepared
.iter()
.map(|message| message.role.as_str())
.collect();
let contents: Vec<&str> = prepared
.iter()
.map(|message| match message.content.as_ref() {
Some(OpenAIContent::Text(text)) => text.as_str(),
_ => "",
})
.collect();
assert_eq!(roles, vec!["system", "user", "assistant"]);
assert_eq!(
contents,
vec!["instructions-b", "second-user", "second-assistant"]
);
}
#[test]
fn auto_memory_request_drops_leading_tool_messages() {
let loaded_messages = vec![
text_message("tool", "stale-tool-result"),
text_message("user", "hello"),
text_message("assistant", "hi"),
];
let prepared = prepare_auto_memory_messages_for_request(&loaded_messages, 10);
let roles: Vec<&str> = prepared
.iter()
.map(|message| message.role.as_str())
.collect();
assert_eq!(roles, vec!["user", "assistant"]);
}
#[test]
fn auto_memory_persistence_excludes_system_messages() {
let all_messages = vec![
text_message("system", "instructions"),
text_message("user", "hello"),
text_message("assistant", "hi"),
text_message("system", "duplicate-instructions"),
text_message("user", "follow-up"),
];
let persisted = prepare_auto_memory_messages_for_persistence(&all_messages, 10);
let roles: Vec<&str> = persisted
.iter()
.map(|message| message.role.as_str())
.collect();
assert_eq!(roles, vec!["user", "assistant", "user"]);
}
}
/// Handle credentials check mode - check credentials without making API calls
async fn handle_credentials_check(provider: &ProviderWithResource) -> Result<Box<RawValue>, Error> {
let result = match &provider.kind {
@@ -4,17 +4,56 @@ Memory tests for AI agents.
Tests that AI agents correctly handle conversation memory/history.
"""
import os
import pytest
import uuid
from typing import Any
from .conftest import AIAgentTestClient, create_ai_agent_flow
from .providers import ALL_PROVIDERS, get_provider_ids
from .providers import (
ALL_PROVIDERS,
BEDROCK_API_KEY,
BEDROCK_ENV,
BEDROCK_IAM,
BEDROCK_IAM_SESSION,
get_provider_ids,
)
class TestMemory:
"""Test AI agent memory functionality."""
@staticmethod
def _pick_bedrock_provider() -> dict[str, Any]:
if os.environ.get("BEDROCK_API_KEY"):
return BEDROCK_API_KEY
if (
os.environ.get("BEDROCK_IAM_ACCESS_KEY_ID")
and os.environ.get("BEDROCK_IAM_SECRET_ACCESS_KEY")
):
return BEDROCK_IAM
if (
os.environ.get("BEDROCK_SESSION_ACCESS_KEY_ID")
and os.environ.get("BEDROCK_SESSION_SECRET_ACCESS_KEY")
and os.environ.get("BEDROCK_SESSION_TOKEN")
):
return BEDROCK_IAM_SESSION
aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
aws_session_token = os.environ.get("AWS_SESSION_TOKEN")
if aws_access_key_id and aws_secret_access_key:
if aws_access_key_id.startswith("ASIA") and not aws_session_token:
pytest.skip(
"AWS_SESSION_TOKEN required for Bedrock env fallback when AWS_ACCESS_KEY_ID is temporary (ASIA...)"
)
return BEDROCK_ENV
pytest.skip("No Bedrock credentials available for the memory regression test")
@pytest.mark.parametrize(
"provider_config",
ALL_PROVIDERS,
@@ -65,6 +104,61 @@ class TestMemory:
print(f"Memory test passed for {provider_config['name']}")
def test_memory_keeps_single_system_prompt_across_turns(
self,
client: AIAgentTestClient,
setup_providers,
):
"""
Test that auto memory persists conversation turns without persisting
duplicate system prompts across repeated runs.
"""
provider_config = self._pick_bedrock_provider()
system_prompt = "You are a helpful assistant. Keep responses short."
flow_value = create_ai_agent_flow(
provider_input_transform=provider_config["input_transform"],
system_prompt=system_prompt,
context_length=6,
)
memory_id = str(uuid.uuid4())
turns = [
"Turn one: say hi.",
"Turn two: say hi again.",
"Turn three: say hi one more time.",
]
result = None
for turn in turns:
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": turn},
memory_id=memory_id,
)
assert result is not None
assert "error" not in result, f"Turn failed for {provider_config['name']}: {result}"
assert result is not None
messages = result.get("messages", [])
system_messages = [msg for msg in messages if msg.get("role") == "system"]
assert len(system_messages) == 1, (
f"Expected exactly one system message for {provider_config['name']}, got: {messages}"
)
assert system_messages[0].get("content") == system_prompt
user_messages = [
msg.get("content")
for msg in messages
if msg.get("role") == "user"
]
assert user_messages == turns, (
f"Expected all prior user turns to remain in memory for {provider_config['name']}: {messages}"
)
print(f"System prompt dedupe test passed for {provider_config['name']}")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])