mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user