use bamboo_agent_core::{
ContextBlock, ContextBlockPriority, ContextBlockStability, ContextBlockType, Message, Session,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default)]
pub struct PromptEnvelope {
pub stable_instructions: String,
pub stable_prefix_messages: Vec<Message>,
pub dynamic_context_messages: Vec<Message>,
pub conversation_messages: Vec<Message>,
pub observability: PromptEnvelopeObservability,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptEnvelopeObservability {
pub stable_instructions_chars: usize,
pub stable_prefix_message_count: usize,
pub dynamic_context_message_count: usize,
pub conversation_message_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stable_prefix_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_context_hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub included_block_types: Vec<ContextBlockType>,
}
#[derive(Debug, Clone)]
pub struct StablePromptFrame {
pub stable_instructions: String,
pub stable_prefix_messages: Vec<Message>,
}
impl StablePromptFrame {
pub fn new(
stable_instructions: impl Into<String>,
stable_prefix_messages: Vec<Message>,
) -> Self {
Self {
stable_instructions: stable_instructions.into(),
stable_prefix_messages,
}
}
}
const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
const EXTERNAL_MEMORY_END_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_END -->";
const PLAN_MODE_START_MARKER: &str = "<!-- BAMBOO_PLAN_MODE_START -->";
const PLAN_MODE_END_MARKER: &str = "<!-- BAMBOO_PLAN_MODE_END -->";
const PLAN_RUNTIME_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PLAN_RUNTIME_CONTEXT_START -->";
const PLAN_RUNTIME_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PLAN_RUNTIME_CONTEXT_END -->";
pub(crate) fn render_context_block_message(block: &ContextBlock) -> Message {
block.render_runtime_context_message()
}
pub(crate) fn assemble_prompt_envelope(
stable: StablePromptFrame,
dynamic_blocks: Vec<ContextBlock>,
conversation_messages: Vec<Message>,
) -> PromptEnvelope {
let dynamic_context_messages: Vec<Message> = dynamic_blocks
.iter()
.map(render_context_block_message)
.collect();
let observability = PromptEnvelopeObservability {
stable_instructions_chars: stable.stable_instructions.len(),
stable_prefix_message_count: stable.stable_prefix_messages.len(),
dynamic_context_message_count: dynamic_context_messages.len(),
conversation_message_count: conversation_messages.len(),
stable_prefix_hash: None,
dynamic_context_hash: None,
included_block_types: dynamic_blocks.iter().map(|block| block.block_type).collect(),
};
PromptEnvelope {
stable_instructions: stable.stable_instructions,
stable_prefix_messages: stable.stable_prefix_messages,
dynamic_context_messages,
conversation_messages,
observability,
}
}
pub(crate) fn envelope_to_chat_messages(envelope: &PromptEnvelope) -> Vec<Message> {
let mut messages = Vec::new();
if !envelope.stable_instructions.trim().is_empty() {
messages.push(Message::system(envelope.stable_instructions.trim().to_string()));
}
messages.extend(envelope.stable_prefix_messages.clone());
messages.extend(envelope.dynamic_context_messages.clone());
messages.extend(envelope.conversation_messages.clone());
messages
}
#[derive(Debug, Clone, Default)]
pub struct ResponsesPromptView {
pub instructions: Option<String>,
pub input_messages: Vec<Message>,
}
fn extract_wrapped_section(prompt: &str, start_marker: &str, end_marker: &str) -> Option<String> {
let start_idx = prompt.find(start_marker)?;
let section_start = start_idx + start_marker.len();
let end_rel_idx = prompt[section_start..].find(end_marker)?;
let section_end = section_start + end_rel_idx;
let section = prompt[start_idx..section_end + end_marker.len()].trim();
(!section.is_empty()).then(|| section.to_string())
}
fn strip_wrapped_markers(section: &str, start_marker: &str, end_marker: &str) -> String {
section
.trim()
.strip_prefix(start_marker)
.map(str::trim_start)
.and_then(|value| value.strip_suffix(end_marker).map(str::trim_end))
.unwrap_or(section)
.trim()
.to_string()
}
pub(crate) fn build_task_list_context_block(session: &Session) -> Option<ContextBlock> {
let content = session.format_task_list_for_prompt();
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
let title = session
.task_list
.as_ref()
.map(|task_list| format!("Current Task List: {}", task_list.title.trim()))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Current Task List".to_string());
Some(ContextBlock::new(
ContextBlockType::TaskSnapshot,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
title,
trimmed,
))
}
#[cfg(test)]
pub(crate) fn build_external_memory_context_block(session: &Session) -> Option<ContextBlock> {
build_external_memory_context_block_from_messages(&session.messages)
}
pub(crate) fn build_external_memory_context_block_from_messages(
messages: &[Message],
) -> Option<ContextBlock> {
let system_message = messages
.iter()
.find(|message| matches!(message.role, bamboo_agent_core::Role::System))?;
let content = extract_wrapped_section(
&system_message.content,
EXTERNAL_MEMORY_START_MARKER,
EXTERNAL_MEMORY_END_MARKER,
)?;
let trimmed = strip_wrapped_markers(
&content,
EXTERNAL_MEMORY_START_MARKER,
EXTERNAL_MEMORY_END_MARKER,
);
if trimmed.trim().is_empty() {
return None;
}
Some(ContextBlock::new(
ContextBlockType::ExternalMemory,
ContextBlockPriority::Medium,
ContextBlockStability::RoundDynamic,
"External Memory (Persistent)",
trimmed,
))
}
pub(crate) fn build_plan_mode_context_block_from_messages(
messages: &[Message],
) -> Option<ContextBlock> {
let system_message = messages
.iter()
.find(|message| matches!(message.role, bamboo_agent_core::Role::System))?;
let content = extract_wrapped_section(
&system_message.content,
PLAN_MODE_START_MARKER,
PLAN_MODE_END_MARKER,
)?;
let trimmed = strip_wrapped_markers(&content, PLAN_MODE_START_MARKER, PLAN_MODE_END_MARKER);
if trimmed.trim().is_empty() {
return None;
}
Some(ContextBlock::new(
ContextBlockType::PlanModeState,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
"Plan Mode State",
trimmed,
))
}
pub(crate) fn build_plan_runtime_context_block_from_messages(
messages: &[Message],
) -> Option<ContextBlock> {
let system_message = messages
.iter()
.find(|message| matches!(message.role, bamboo_agent_core::Role::System))?;
let content = extract_wrapped_section(
&system_message.content,
PLAN_RUNTIME_CONTEXT_START_MARKER,
PLAN_RUNTIME_CONTEXT_END_MARKER,
)?;
let trimmed = strip_wrapped_markers(
&content,
PLAN_RUNTIME_CONTEXT_START_MARKER,
PLAN_RUNTIME_CONTEXT_END_MARKER,
);
if trimmed.trim().is_empty() {
return None;
}
Some(ContextBlock::new(
ContextBlockType::PlanRuntimeState,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
"Durable Plan Execution Context",
trimmed,
))
}
pub(crate) fn build_conversation_summary_context_block(session: &Session) -> Option<ContextBlock> {
let summary = session.conversation_summary.as_ref()?;
let trimmed = summary.content.trim();
if trimmed.is_empty() {
return None;
}
Some(ContextBlock::new(
ContextBlockType::ConversationSummary,
ContextBlockPriority::Medium,
ContextBlockStability::RoundDynamic,
"Previous Conversation Summary",
format!(
"The following is compressed historical context for continuity only.\nIt is background memory, not a new user request. Follow the current task list and recent messages over this summary when they conflict.\n\n{}",
trimmed
),
))
}
pub(crate) fn envelope_to_responses_view(envelope: &PromptEnvelope) -> ResponsesPromptView {
let instructions = envelope
.stable_instructions
.trim()
.is_empty()
.then_some(String::new())
.filter(|value| !value.is_empty())
.or_else(|| {
let trimmed = envelope.stable_instructions.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
});
let mut input_messages = Vec::new();
input_messages.extend(envelope.stable_prefix_messages.clone());
input_messages.extend(envelope.dynamic_context_messages.clone());
input_messages.extend(envelope.conversation_messages.clone());
ResponsesPromptView {
instructions,
input_messages,
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_agent_core::agent::types::{TaskItem, TaskItemStatus, TaskList};
use bamboo_agent_core::Role;
use chrono::Utc;
#[test]
fn render_context_block_message_marks_runtime_context_and_metadata() {
let block = ContextBlock::new(
ContextBlockType::TaskSnapshot,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
"Current Task Snapshot",
"- task: build prompt envelope skeleton",
);
let rendered = render_context_block_message(&block);
assert_eq!(rendered.role, Role::User);
assert!(rendered.content.contains("BAMBOO_CONTEXT_BLOCK_START"));
assert!(rendered.content.contains("context_type: task_snapshot"));
assert!(rendered
.content
.contains("It is not a new user request."));
assert!(rendered.never_compress);
assert!(rendered.metadata.is_some());
}
#[test]
fn assemble_prompt_envelope_tracks_counts_and_block_types() {
let stable = StablePromptFrame::new("stable instructions", vec![Message::user("stable")]);
let blocks = vec![ContextBlock::new(
ContextBlockType::ConversationSummary,
ContextBlockPriority::Medium,
ContextBlockStability::RoundDynamic,
"Summary",
"old context",
)];
let conversation = vec![Message::assistant("answer", None)];
let envelope = assemble_prompt_envelope(stable, blocks, conversation);
assert_eq!(envelope.observability.stable_prefix_message_count, 1);
assert_eq!(envelope.observability.dynamic_context_message_count, 1);
assert_eq!(envelope.observability.conversation_message_count, 1);
assert_eq!(
envelope.observability.included_block_types,
vec![ContextBlockType::ConversationSummary]
);
}
#[test]
fn envelope_to_chat_messages_includes_single_stable_system_prefix() {
let stable = StablePromptFrame::new("stable instructions", vec![Message::user("stable")]);
let blocks = vec![ContextBlock::new(
ContextBlockType::TaskSnapshot,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
"Task",
"do the thing",
)];
let envelope = assemble_prompt_envelope(stable, blocks, vec![Message::user("latest user")]);
let messages = envelope_to_chat_messages(&envelope);
assert!(matches!(messages.first().map(|m| &m.role), Some(Role::System)));
assert_eq!(messages.len(), 4);
}
#[test]
fn envelope_to_responses_view_uses_instructions_without_system_message() {
let stable = StablePromptFrame::new("stable instructions", vec![Message::user("stable")]);
let envelope = assemble_prompt_envelope(
stable,
vec![ContextBlock::new(
ContextBlockType::TaskSnapshot,
ContextBlockPriority::High,
ContextBlockStability::RoundDynamic,
"Task",
"do the thing",
)],
vec![Message::user("latest user")],
);
let view = envelope_to_responses_view(&envelope);
assert_eq!(view.instructions.as_deref(), Some("stable instructions"));
assert_eq!(view.input_messages.len(), 3);
assert!(!matches!(
view.input_messages.first().map(|m| &m.role),
Some(Role::System)
));
}
#[test]
fn build_task_list_context_block_uses_formatted_prompt_content() {
let mut session = Session::new("session-task-block", "model");
session.task_list = Some(TaskList {
session_id: session.id.clone(),
title: "Agent Tasks".to_string(),
items: vec![TaskItem {
id: "task-1".to_string(),
description: "Implement prompt envelope".to_string(),
status: TaskItemStatus::InProgress,
..TaskItem::default()
}],
created_at: Utc::now(),
updated_at: Utc::now(),
});
let block = build_task_list_context_block(&session).expect("task block should exist");
assert_eq!(block.block_type, ContextBlockType::TaskSnapshot);
assert_eq!(block.priority, ContextBlockPriority::High);
assert!(block.content.contains("Current Task List"));
assert!(block.content.contains("Implement prompt envelope"));
}
#[test]
fn build_external_memory_context_block_extracts_wrapped_system_section() {
let mut session = Session::new("session-external-memory-block", "model");
session.add_message(Message::system(
"Base prompt\n\n<!-- BAMBOO_EXTERNAL_MEMORY_START -->\n## External Memory (Persistent)\n\nSession note body\n<!-- BAMBOO_EXTERNAL_MEMORY_END -->",
));
let block = build_external_memory_context_block(&session)
.expect("external memory block should exist");
assert_eq!(block.block_type, ContextBlockType::ExternalMemory);
assert_eq!(block.priority, ContextBlockPriority::Medium);
assert!(block.content.contains("## External Memory (Persistent)"));
assert!(block.content.contains("Session note body"));
assert!(!block.content.contains("BAMBOO_EXTERNAL_MEMORY_START"));
}
#[test]
fn build_plan_mode_context_block_extracts_wrapped_system_section() {
let messages = vec![Message::system(
"Base prompt\n\n<!-- BAMBOO_PLAN_MODE_START -->\n=== PLAN MODE ACTIVE ===\n\nEXPLORE\n<!-- BAMBOO_PLAN_MODE_END -->",
)];
let block = build_plan_mode_context_block_from_messages(&messages)
.expect("plan mode block should exist");
assert_eq!(block.block_type, ContextBlockType::PlanModeState);
assert_eq!(block.priority, ContextBlockPriority::High);
assert!(block.content.contains("PLAN MODE ACTIVE"));
assert!(!block.content.contains("BAMBOO_PLAN_MODE_START"));
}
#[test]
fn build_plan_runtime_context_block_extracts_wrapped_system_section() {
let messages = vec![Message::system(
"Base prompt\n\n<!-- BAMBOO_PLAN_RUNTIME_CONTEXT_START -->\n=== DURABLE PLAN EXECUTION CONTEXT ===\n\nResume rule\n<!-- BAMBOO_PLAN_RUNTIME_CONTEXT_END -->",
)];
let block = build_plan_runtime_context_block_from_messages(&messages)
.expect("plan runtime block should exist");
assert_eq!(block.block_type, ContextBlockType::PlanRuntimeState);
assert_eq!(block.priority, ContextBlockPriority::High);
assert!(block.content.contains("DURABLE PLAN EXECUTION CONTEXT"));
assert!(!block.content.contains("BAMBOO_PLAN_RUNTIME_CONTEXT_START"));
}
#[test]
fn build_conversation_summary_context_block_wraps_summary_content() {
let mut session = Session::new("session-summary-block", "model");
session.conversation_summary = Some(bamboo_agent_core::ConversationSummary::new(
"Older work was compressed.",
3,
120,
));
let block = build_conversation_summary_context_block(&session)
.expect("summary block should exist");
assert_eq!(block.block_type, ContextBlockType::ConversationSummary);
assert_eq!(block.priority, ContextBlockPriority::Medium);
assert!(block.content.contains("compressed historical context"));
assert!(block.content.contains("Older work was compressed."));
}
}