fix(context): preserve cache-boundary metadata through prompt builders (#506)

* feat(prompt): enhance system prompt handling with cache boundary support

- Updated the `SystemPromptBuilder` to include a new method for building prompts with cache metadata, returning a `RenderedPrompt` struct that contains the prompt text and an optional cache boundary index.
- Introduced a constant for the cache boundary marker to improve readability and maintainability.
- Modified the `extract_cache_boundary` function to cleanly remove the cache boundary marker from the rendered prompt while returning the relevant index.
- Updated tests to validate the new cache boundary functionality and ensure that the system prompt does not leak internal markers.
- Adjusted the agent's session management to track the cache boundary, enhancing the overall prompt handling and memory context management.

* feat(context): add method to build system prompt with cache metadata

- Introduced `build_system_prompt_with_cache_metadata` method in `ContextManager` to assemble the opening system prompt while preserving cache-boundary metadata for improved request prefix caching.
- Updated imports to include `RenderedPrompt` for the new functionality, enhancing the context management capabilities.

* feat(prompt): include cache boundary in subagent system prompt rendering

- Updated `render_subagent_system_prompt` to insert a cache boundary marker before the workspace section, allowing typed sub-agents to maintain static instructions above this boundary for efficient prompt reuse.
- Added a new test to verify that the cache boundary is correctly included in the rendered prompt, ensuring that the system prompt structure supports improved caching behavior.
- Adjusted comments for clarity and updated the runtime banner numbering to reflect the new structure.
This commit is contained in:
Steven Enamakel
2026-04-11 11:53:17 -07:00
committed by GitHub
parent 69c4fa46cc
commit 362d0a014f
8 changed files with 243 additions and 46 deletions
@@ -267,6 +267,7 @@ impl AgentBuilder {
skills: self.skills.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
last_memory_context: None,
system_prompt_cache_boundary: None,
history: Vec::new(),
post_turn_hooks: self.post_turn_hooks,
learning_enabled: self.learning_enabled,
@@ -113,6 +113,7 @@ impl Agent {
/// Clears the agent's conversation history.
pub fn clear_history(&mut self) {
self.history.clear();
self.system_prompt_cache_boundary = None;
}
// ─────────────────────────────────────────────────────────────────
@@ -63,6 +63,7 @@ struct RecordingProvider {
struct CapturedCall {
system_prompt: Option<String>,
model: String,
cache_boundary: Option<usize>,
}
#[async_trait]
@@ -91,6 +92,7 @@ impl Provider for RecordingProvider {
self.captures.lock().push(CapturedCall {
system_prompt,
model: model.to_string(),
cache_boundary: request.system_prompt_cache_boundary,
});
let mut guard = self.responses.lock();
@@ -578,5 +580,19 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() {
"model name flipped on turn {} — KV cache namespace broken",
idx
);
assert_eq!(
cap.cache_boundary, captures[0].cache_boundary,
"cache boundary drifted on turn {} — provider prompt caching became unstable",
idx
);
assert!(
cap.cache_boundary.is_some(),
"turn {} should carry an explicit prompt cache boundary",
idx
);
assert!(
!sys.contains("<!-- CACHE_BOUNDARY -->"),
"system prompt should not leak the internal cache marker"
);
}
}
+17 -10
View File
@@ -24,7 +24,9 @@ use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::dispatcher::{ParsedToolCall, ToolExecutionResult};
use crate::openhuman::agent::harness;
use crate::openhuman::agent::hooks::{self, ToolCallRecord, TurnContext};
use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool};
use crate::openhuman::context::prompt::{
LearnedContextData, PromptContext, PromptTool, RenderedPrompt,
};
use crate::openhuman::context::{ReductionOutcome, ARCHIVIST_EXTRACTION_PROMPT};
use crate::openhuman::memory::MemoryCategory;
use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage};
@@ -55,16 +57,17 @@ impl Agent {
// inference backend has already tokenised. Fetching it later
// would just burn memory-store reads on data we throw away.
let learned = self.fetch_learned_context().await;
let system_prompt = self.build_system_prompt(learned)?;
let rendered_prompt = self.build_system_prompt(learned)?;
log::info!("[agent] system prompt built — initialising conversation history");
log::info!(
"[agent_loop] system prompt built chars={}",
system_prompt.chars().count()
rendered_prompt.text.chars().count()
);
log::debug!("[agent_loop] system prompt body:\n{system_prompt}");
log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt.text);
self.system_prompt_cache_boundary = rendered_prompt.cache_boundary;
self.history
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
rendered_prompt.text,
)));
} else {
// Deliberately do NOT rebuild the system prompt on subsequent
@@ -239,7 +242,7 @@ impl Agent {
} else {
None
},
system_prompt_cache_boundary: None,
system_prompt_cache_boundary: self.system_prompt_cache_boundary,
},
&effective_model,
self.temperature,
@@ -656,7 +659,7 @@ impl Agent {
system_prompt: Arc::new(system_prompt),
tool_specs: Arc::clone(&self.visible_tool_specs),
message_prefix: Arc::new(messages),
cache_boundary: None,
cache_boundary: self.system_prompt_cache_boundary,
fork_task_prompt,
}
}
@@ -751,7 +754,10 @@ impl Agent {
/// Builds the system prompt for the current turn, including tool
/// instructions and learned context.
pub(super) fn build_system_prompt(&self, learned: LearnedContextData) -> Result<String> {
pub(super) fn build_system_prompt(
&self,
learned: LearnedContextData,
) -> Result<RenderedPrompt> {
let tools_slice: &[Box<dyn Tool>] = self.tools.as_slice();
let instructions = self.tool_dispatcher.prompt_instructions(tools_slice);
// Adapt the owned Box<dyn Tool> slice into the shared PromptTool
@@ -770,8 +776,9 @@ impl Agent {
};
// Route through the global context manager so every
// prompt-building call-site — main agent, sub-agent runner,
// channel runtimes — shares one builder configuration.
self.context.build_system_prompt(&ctx)
// channel runtimes — shares one builder configuration while
// still preserving cache-boundary metadata for provider calls.
self.context.build_system_prompt_with_cache_metadata(&ctx)
}
// ─────────────────────────────────────────────────────────────────
@@ -50,6 +50,9 @@ pub struct Agent {
/// Last memory context loaded for the current turn. Stored so it can
/// be forwarded to subagents via `ParentExecutionContext`.
pub(super) last_memory_context: Option<String>,
/// Explicit cache boundary for the current rendered system prompt.
/// `None` means the prompt is treated as entirely dynamic.
pub(super) system_prompt_cache_boundary: Option<usize>,
pub(super) history: Vec<ConversationMessage>,
pub(super) post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
pub(super) learning_enabled: bool,
+108 -27
View File
@@ -30,6 +30,9 @@
use super::definition::{AgentDefinition, PromptSource, ToolScope};
use super::fork_context::{current_fork, current_parent, ForkContext, ParentExecutionContext};
use crate::openhuman::context::prompt::{
extract_cache_boundary, render_subagent_system_prompt, SubagentRenderOptions,
};
use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall};
use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec};
use std::collections::HashSet;
@@ -238,27 +241,31 @@ async fn run_typed_mode(
// hard-coded all three as "omit", which silently downgraded
// definitions like `code_executor` / `tool_maker` / `skills_agent`
// that set `omit_safety_preamble = false`.
let render_options =
crate::openhuman::context::prompt::SubagentRenderOptions::from_definition_flags(
definition.omit_identity,
definition.omit_safety_preamble,
definition.omit_skills_catalog,
);
let system_prompt = crate::openhuman::context::prompt::render_subagent_system_prompt(
let render_options = SubagentRenderOptions::from_definition_flags(
definition.omit_identity,
definition.omit_safety_preamble,
definition.omit_skills_catalog,
);
let rendered_prompt = extract_cache_boundary(&render_subagent_system_prompt(
&parent.workspace_dir,
&model,
&allowed_indices,
&parent.all_tools,
&archetype_prompt_body,
render_options,
);
));
let system_prompt = rendered_prompt.text;
let system_prompt_cache_boundary = rendered_prompt.cache_boundary;
// ── Build the user message (with optional context prefix) ──────────
// Merge explicit context from the orchestrator with the parent's
// auto-loaded memory context so the subagent has full visibility.
// Merge explicit orchestrator context with the parent's auto-loaded
// memory context, but only when the definition opts into memory
// inheritance.
let mut context_parts: Vec<&str> = Vec::new();
if let Some(ref mem_ctx) = parent.memory_context {
context_parts.push(mem_ctx);
if !definition.omit_memory_context {
if let Some(ref mem_ctx) = parent.memory_context {
context_parts.push(mem_ctx);
}
}
if let Some(ref ctx) = options.context {
context_parts.push(ctx);
@@ -284,6 +291,7 @@ async fn run_typed_mode(
&model,
temperature,
definition.max_iterations,
system_prompt_cache_boundary,
task_id,
&definition.id,
)
@@ -341,8 +349,10 @@ async fn run_fork_mode(
let mut history: Vec<ChatMessage> = (*fork.message_prefix).clone();
history.push(ChatMessage::user(fork_task_prompt));
// All parent tools are allowed in fork mode (the whole point is
// byte-identical tool schemas).
// Fork mode keeps the parent's exact tool schema snapshot so the
// request body matches the prefix the backend has already cached.
// Runtime execution still resolves against the parent's live tool
// registry.
let allowed_names: HashSet<String> = parent
.all_tools
.iter()
@@ -358,11 +368,12 @@ async fn run_fork_mode(
parent.provider.as_ref(),
&mut history,
&parent.all_tools,
&parent.all_tool_specs,
fork.tool_specs.as_slice(),
&allowed_names,
&model,
temperature,
max_iterations,
fork.cache_boundary,
task_id,
&definition.id,
)
@@ -392,6 +403,7 @@ async fn run_inner_loop(
model: &str,
temperature: f64,
max_iterations: usize,
system_prompt_cache_boundary: Option<usize>,
task_id: &str,
agent_id: &str,
) -> Result<(String, usize), SubagentRunError> {
@@ -417,7 +429,7 @@ async fn run_inner_loop(
ChatRequest {
messages: history.as_slice(),
tools: request_tools,
system_prompt_cache_boundary: None,
system_prompt_cache_boundary,
},
model,
temperature,
@@ -873,9 +885,16 @@ mod tests {
/// Mock provider whose response queue can be inspected by the test
/// to verify the bytes that arrive at the model.
#[derive(Clone)]
struct CapturedRequest {
messages: Vec<crate::openhuman::providers::ChatMessage>,
cache_boundary: Option<usize>,
tool_count: usize,
}
struct ScriptedProvider {
responses: Mutex<Vec<ChatResponse>>,
captured: Mutex<Vec<Vec<crate::openhuman::providers::ChatMessage>>>,
captured: Mutex<Vec<CapturedRequest>>,
}
impl ScriptedProvider {
@@ -905,7 +924,11 @@ mod tests {
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
self.captured.lock().push(request.messages.to_vec());
self.captured.lock().push(CapturedRequest {
messages: request.messages.to_vec(),
cache_boundary: request.system_prompt_cache_boundary,
tool_count: request.tools.map_or(0, |tools| tools.len()),
});
let mut q = self.responses.lock();
if q.is_empty() {
return Ok(ChatResponse {
@@ -1067,6 +1090,7 @@ mod tests {
let captured = provider.captured.lock();
assert_eq!(captured.len(), 1);
let user_msg = captured[0]
.messages
.iter()
.find(|m| m.role == "user")
.expect("user message should be present");
@@ -1078,6 +1102,60 @@ mod tests {
assert!(user_msg.content.contains("the actual task prompt"));
}
#[tokio::test]
async fn typed_mode_includes_memory_context_when_definition_allows_it() {
let provider = ScriptedProvider::new(vec![text_response("ok")]);
let mut parent = make_parent(provider.clone(), vec![stub("file_read")]);
parent.memory_context = Some("[Memory context]\n- prior fact: branch X failed\n".into());
let mut def = make_def_named_tools(&[]);
def.omit_memory_context = false;
let _ = with_parent_context(parent, async {
run_subagent(
&def,
"the actual task prompt",
SubagentRunOptions::default(),
)
.await
})
.await
.unwrap();
let captured = provider.captured.lock();
let user_msg = captured[0]
.messages
.iter()
.find(|m| m.role == "user")
.expect("user message should be present");
assert!(user_msg.content.contains("[Memory context]"));
assert!(user_msg.content.contains("branch X failed"));
}
#[tokio::test]
async fn typed_mode_threads_system_prompt_cache_boundary() {
let provider = ScriptedProvider::new(vec![text_response("ok")]);
let parent = make_parent(provider.clone(), vec![stub("file_read")]);
let def = make_def_named_tools(&[]);
let _ = with_parent_context(parent, async {
run_subagent(
&def,
"the actual task prompt",
SubagentRunOptions::default(),
)
.await
})
.await
.unwrap();
let captured = provider.captured.lock();
assert_eq!(captured.len(), 1);
assert!(
captured[0].cache_boundary.is_some(),
"typed sub-agent request should carry a prompt cache boundary"
);
}
#[tokio::test]
async fn typed_mode_filters_tools_by_skill_filter() {
// Parent has tools spanning notion__*, gmail__*, and a generic
@@ -1117,6 +1195,7 @@ mod tests {
// name and NOT mention gmail/file_read.
let captured = provider.captured.lock();
let system_msg = captured[0]
.messages
.iter()
.find(|m| m.role == "system")
.expect("system message present");
@@ -1157,7 +1236,7 @@ mod tests {
// by the runner from StubTool's "ok" output.
let captured = provider.captured.lock();
assert_eq!(captured.len(), 2);
let second_call_messages = &captured[1];
let second_call_messages = &captured[1].messages;
let has_tool_msg = second_call_messages.iter().any(|m| m.role == "tool");
assert!(
has_tool_msg,
@@ -1189,7 +1268,7 @@ mod tests {
assert!(outcome.output.contains("oops"));
let captured = provider.captured.lock();
let second_call_messages = &captured[1];
let second_call_messages = &captured[1].messages;
let tool_msg = second_call_messages
.iter()
.find(|m| m.role == "tool")
@@ -1206,7 +1285,7 @@ mod tests {
// The runner should replay it byte-for-byte plus a single
// appended user message carrying the fork directive.
let provider = ScriptedProvider::new(vec![text_response("fork done")]);
let parent = make_parent(provider.clone(), vec![stub("file_read")]);
let parent = make_parent(provider.clone(), vec![stub("file_read"), stub("shell")]);
let prefix = vec![
crate::openhuman::providers::ChatMessage::system("PARENT_SYSTEM_PROMPT_BYTES"),
@@ -1216,9 +1295,9 @@ mod tests {
let fork = ForkContext {
system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()),
tool_specs: Arc::clone(&parent.all_tool_specs),
tool_specs: Arc::new(vec![parent.all_tool_specs[0].clone()]),
message_prefix: Arc::new(prefix.clone()),
cache_boundary: None,
cache_boundary: Some(9),
fork_task_prompt: "ANALYSE THIS BRANCH".into(),
};
@@ -1245,15 +1324,17 @@ mod tests {
// prefix exactly and appends only the fork directive.
let captured = provider.captured.lock();
let first_call = &captured[0];
assert_eq!(first_call.len(), prefix.len() + 1);
assert_eq!(first_call.messages.len(), prefix.len() + 1);
for (i, msg) in prefix.iter().enumerate() {
assert_eq!(first_call[i].role, msg.role);
assert_eq!(first_call[i].content, msg.content);
assert_eq!(first_call.messages[i].role, msg.role);
assert_eq!(first_call.messages[i].content, msg.content);
}
// The appended user message carries the fork directive.
let appended = first_call.last().unwrap();
let appended = first_call.messages.last().unwrap();
assert_eq!(appended.role, "user");
assert_eq!(appended.content, "ANALYSE THIS BRANCH");
assert_eq!(first_call.cache_boundary, Some(9));
assert_eq!(first_call.tool_count, 1);
}
#[tokio::test]
+11 -1
View File
@@ -33,7 +33,7 @@ use std::sync::Arc;
use super::pipeline::{
ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle,
};
use super::prompt::{PromptContext, SystemPromptBuilder};
use super::prompt::{PromptContext, RenderedPrompt, SystemPromptBuilder};
use super::session_memory::SessionMemoryConfig;
use super::summarizer::{Summarizer, SummaryStats};
use crate::openhuman::config::ContextConfig;
@@ -234,6 +234,16 @@ impl ContextManager {
self.default_prompt_builder.build(ctx)
}
/// Assemble the opening system prompt for a session using the
/// manager's default builder and preserve cache-boundary metadata
/// for provider request prefix caching.
pub fn build_system_prompt_with_cache_metadata(
&self,
ctx: &PromptContext<'_>,
) -> Result<RenderedPrompt> {
self.default_prompt_builder.build_with_cache_metadata(ctx)
}
/// Assemble the system prompt via a caller-supplied builder.
///
/// Sub-agents pass `SystemPromptBuilder::for_subagent(...)` and
+86 -8
View File
@@ -6,6 +6,7 @@ use std::fmt::Write;
use std::path::Path;
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
const CACHE_BOUNDARY_MARKER: &str = "<!-- CACHE_BOUNDARY -->";
/// Pre-fetched learned context data for prompt sections (avoids blocking the runtime).
#[derive(Debug, Clone, Default)]
@@ -91,6 +92,12 @@ pub trait PromptSection: Send + Sync {
fn build(&self, ctx: &PromptContext<'_>) -> Result<String>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPrompt {
pub text: String,
pub cache_boundary: Option<usize>,
}
#[derive(Default)]
pub struct SystemPromptBuilder {
sections: Vec<Box<dyn PromptSection>>,
@@ -165,6 +172,10 @@ impl SystemPromptBuilder {
}
pub fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
Ok(self.build_with_cache_metadata(ctx)?.text)
}
pub fn build_with_cache_metadata(&self, ctx: &PromptContext<'_>) -> Result<RenderedPrompt> {
let mut output = String::new();
let mut cache_boundary_inserted = false;
for section in &self.sections {
@@ -176,13 +187,34 @@ impl SystemPromptBuilder {
// Static sections (identity, tools, safety, skills) are cacheable;
// dynamic sections (workspace, datetime, runtime) change per request.
if !cache_boundary_inserted && is_dynamic_section(section.name()) {
output.push_str("<!-- CACHE_BOUNDARY -->\n\n");
output.push_str(CACHE_BOUNDARY_MARKER);
output.push_str("\n\n");
cache_boundary_inserted = true;
}
output.push_str(part.trim_end());
output.push_str("\n\n");
}
Ok(output)
Ok(extract_cache_boundary(&output))
}
}
pub fn extract_cache_boundary(rendered: &str) -> RenderedPrompt {
if let Some(marker_idx) = rendered.find(CACHE_BOUNDARY_MARKER) {
let mut text = rendered.to_string();
let end = marker_idx + CACHE_BOUNDARY_MARKER.len();
text.replace_range(marker_idx..end, "");
if text[marker_idx..].starts_with("\n\n") {
text.replace_range(marker_idx..marker_idx + 2, "");
}
return RenderedPrompt {
text,
cache_boundary: Some(marker_idx),
};
}
RenderedPrompt {
text: rendered.to_string(),
cache_boundary: None,
}
}
@@ -560,7 +592,14 @@ pub fn render_subagent_system_prompt(
);
}
// 4. Workspace so the model knows where it is. Intentionally stable:
// 4. Insert the cache boundary before the dynamic tail. Typed
// sub-agents keep the narrow/static instructions above this
// marker and thread the resulting byte offset through the
// provider request so repeat spawns can reuse prompt prefill.
out.push_str(CACHE_BOUNDARY_MARKER);
out.push_str("\n\n");
// 5. Workspace so the model knows where it is. Intentionally stable:
// no datetime, no hostname, no pid — see the KV-cache note above.
let _ = writeln!(
out,
@@ -568,7 +607,7 @@ pub fn render_subagent_system_prompt(
workspace_dir.display()
);
// 5. Runtime banner — model name only. Stable for the lifetime of
// 6. Runtime banner — model name only. Stable for the lifetime of
// this sub-agent's definition.
let _ = writeln!(out, "## Runtime\n\nModel: {model_name}");
@@ -719,10 +758,14 @@ mod tests {
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
};
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
assert!(prompt.contains("## Tools"));
assert!(prompt.contains("test_tool"));
assert!(prompt.contains("instr"));
let rendered = SystemPromptBuilder::with_defaults()
.build_with_cache_metadata(&ctx)
.unwrap();
assert!(rendered.text.contains("## Tools"));
assert!(rendered.text.contains("test_tool"));
assert!(rendered.text.contains("instr"));
assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER));
assert!(rendered.cache_boundary.is_some());
}
#[test]
@@ -783,4 +826,39 @@ mod tests {
assert!(payload.contains(" ("));
assert!(payload.ends_with(')'));
}
#[test]
fn extract_cache_boundary_removes_marker_and_returns_offset() {
let rendered = extract_cache_boundary("static\n\n<!-- CACHE_BOUNDARY -->\n\ndynamic\n");
assert_eq!(rendered.text, "static\n\ndynamic\n");
assert_eq!(rendered.cache_boundary, Some("static\n\n".len()));
}
#[test]
fn render_subagent_system_prompt_includes_cache_boundary_before_workspace() {
let workspace = std::env::temp_dir().join(format!(
"openhuman_prompt_subagent_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&workspace).unwrap();
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
let rendered = extract_cache_boundary(&render_subagent_system_prompt(
&workspace,
"test-model",
&[0],
&tools,
"You are a focused sub-agent.",
SubagentRenderOptions::narrow(),
));
assert!(
rendered.cache_boundary.is_some(),
"typed sub-agent prompts should expose an explicit cache boundary"
);
assert!(rendered.text.contains("## Workspace"));
assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER));
let _ = std::fs::remove_dir_all(workspace);
}
}