use crate::context::config::ContextManagementConfig;
use crate::context::model_switch::ModelSwitchRecord;
use crate::context::models::CompressedBlock;
use crate::durable::{DurableSession, SessionId, StructuredMessage};
use crate::skill::SessionSkillState;
use serde::{Deserialize, Serialize};
pub const HANDOFF_BUNDLE_VERSION: &str = "1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HandoffBundleMetadata {
pub version: String,
pub source_model: String,
pub source_provider: Option<String>,
pub source_session_id: String,
pub size_estimate_tokens: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HandoffBundle {
pub version: String,
pub instructions: Option<String>,
pub handoff_note: String,
pub compressed_blocks: Vec<CompressedBlock>,
pub recent_tail: Vec<StructuredMessage>,
pub skill_state: SessionSkillState,
pub metadata: HandoffBundleMetadata,
pub model_switch_history: Vec<ModelSwitchRecord>,
pub current_model: Option<String>,
pub current_provider_slug: Option<String>,
pub current_provider_api_key: Option<crate::secret::SecretString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_identity: Option<String>,
#[serde(default)]
pub hidden_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_id: Option<crate::profile::AgentProfileId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_tool_filter: Option<crate::profile::ToolFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_approval: Option<crate::profile::AgentApproval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_unavailable: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_provider_context:
Option<crate::provider_credential::domain::ProviderPromptContext>,
}
pub struct HandoffExporter;
impl HandoffExporter {
pub fn can_export(session: &DurableSession) -> bool {
!session.tool_records.iter().any(|r| {
matches!(
r.status,
crate::durable::ToolRecordStatus::PendingApproval
| crate::durable::ToolRecordStatus::Running
)
})
}
pub fn export(
session: &DurableSession,
model: &str,
compressed_blocks: &[CompressedBlock],
tail: Vec<StructuredMessage>,
config: &ContextManagementConfig,
provider_name: Option<&str>,
) -> Result<HandoffBundle, String> {
if !Self::can_export(session) {
return Err("Cannot export handoff bundle: session has active tool calls".into());
}
let target_tokens = config.handoff_export.default_target_tokens;
let size_estimate = estimate_bundle_size(
compressed_blocks,
&tail,
session.instruction_text_for_estimate().as_deref(),
&session.skill_state,
);
let mut handoff_note = format!(
"Context transferred from session {} on model {}.",
session.id, model
);
let mut loss_notes = Vec::new();
for msg in &tail {
for block in msg.content_blocks().iter() {
if let crate::durable::ContentBlock::Resource { uri, .. } = block {
if is_local_resource(uri.as_str()) {
loss_notes.push(format!(
"Local resource '{}' may not be accessible at destination",
uri
));
}
}
}
}
if !loss_notes.is_empty() {
handoff_note.push_str(&format!("\nPortability notes: {}", loss_notes.join("; ")));
}
if size_estimate > target_tokens {
handoff_note.push_str(&format!(
"\nBundle size (~{} tokens) exceeds target (~{} tokens).",
size_estimate, target_tokens
));
}
let mut bundle = HandoffBundle {
version: HANDOFF_BUNDLE_VERSION.to_string(),
instructions: session.instructions.clone(),
handoff_note,
compressed_blocks: compressed_blocks.to_vec(),
recent_tail: tail,
skill_state: session.skill_state.clone(),
metadata: HandoffBundleMetadata {
version: HANDOFF_BUNDLE_VERSION.to_string(),
source_model: model.to_string(),
source_provider: provider_name.map(|s| s.to_string()),
source_session_id: session.id.to_string(),
size_estimate_tokens: size_estimate,
},
model_switch_history: session.model_switch_history.clone(),
current_model: session.current_model.clone(),
current_provider_slug: session.current_provider_slug.clone(),
current_provider_api_key: session.current_provider_api_key.clone(),
profile_identity: session.profile_identity.clone(),
hidden_tools: session.hidden_tools.clone(),
profile_id: session.profile_id.clone(),
effective_tool_filter: session.effective_tool_filter.clone(),
effective_approval: session.effective_approval,
effective_model: session.effective_model.clone(),
profile_unavailable: session.profile_unavailable.clone(),
effective_provider_context: session.effective_provider_context.clone(),
};
if config.handoff_export.include_portability_notes && !loss_notes.is_empty() {
for note in loss_notes {
bundle
.handoff_note
.push_str(&format!("\nPortability note: {}", note));
}
}
Ok(bundle)
}
}
pub struct HandoffImporter;
impl HandoffImporter {
pub fn hydrate(target: &mut DurableSession, bundle: HandoffBundle) -> Result<(), String> {
if target.id == SessionId::new() {
return Err(
"Cannot hydrate into a freshly-constructed session with mismatched identity".into(),
);
}
if !bundle.handoff_note.is_empty() {
target.add_agent_text(format!("[Handoff] {}", bundle.handoff_note));
}
if let Some(instr) = bundle.instructions {
target.set_instructions(instr);
}
if let Some(identity) = bundle.profile_identity {
target.set_profile_identity(identity);
}
if !bundle.compressed_blocks.is_empty() {
let rendered = bundle
.compressed_blocks
.iter()
.map(|b| b.render_to_text())
.collect::<Vec<_>>()
.join("\n");
if !rendered.is_empty() {
target.add_agent_text(format!("[Compressed context]\n{}", rendered));
}
}
for msg in bundle.recent_tail {
target.messages.push(msg);
}
target.skill_state = bundle.skill_state;
target.model_switch_history = bundle.model_switch_history.clone();
target.current_model = bundle.current_model.clone();
target.current_provider_slug = bundle.current_provider_slug.clone();
target.current_provider_api_key = bundle.current_provider_api_key.clone();
target.hidden_tools = bundle.hidden_tools.clone();
target.profile_id = bundle.profile_id.clone();
target.effective_tool_filter = bundle.effective_tool_filter.clone();
target.effective_approval = bundle.effective_approval;
target.effective_model = bundle.effective_model.clone();
target.profile_unavailable = bundle.profile_unavailable.clone();
target.effective_provider_context = bundle.effective_provider_context.clone();
for record in &bundle.model_switch_history {
let timeline_index = target.timeline.len() as u64;
target
.timeline
.push(crate::durable::TimelineEntry::ModelSwitched {
index: timeline_index,
from_model: record.from_model.clone(),
to_model: record.to_model.clone(),
from_provider: record.from_provider.clone(),
to_provider: record.to_provider.clone(),
adapted: record.adapted,
visible_id: None,
});
}
Ok(())
}
pub fn hydrate_into_new(bundle: HandoffBundle) -> DurableSession {
let mut session = DurableSession::new(SessionId::new());
if let Some(instr) = bundle.instructions {
session.set_instructions(instr);
}
if let Some(identity) = bundle.profile_identity {
session.set_profile_identity(identity);
}
if !bundle.handoff_note.is_empty() {
session.add_agent_text(format!("[Handoff] {}", bundle.handoff_note));
}
if !bundle.compressed_blocks.is_empty() {
let rendered = bundle
.compressed_blocks
.iter()
.map(|b| b.render_to_text())
.collect::<Vec<_>>()
.join("\n");
if !rendered.is_empty() {
session.add_agent_text(format!("[Compressed context]\n{}", rendered));
}
}
for msg in bundle.recent_tail {
session.messages.push(msg);
}
session.skill_state = bundle.skill_state;
session.model_switch_history = bundle.model_switch_history.clone();
session.current_model = bundle.current_model.clone();
session.current_provider_slug = bundle.current_provider_slug.clone();
session.current_provider_api_key = bundle.current_provider_api_key.clone();
session.hidden_tools = bundle.hidden_tools.clone();
session.profile_id = bundle.profile_id.clone();
session.effective_tool_filter = bundle.effective_tool_filter.clone();
session.effective_approval = bundle.effective_approval;
session.effective_model = bundle.effective_model.clone();
session.profile_unavailable = bundle.profile_unavailable.clone();
session.effective_provider_context = bundle.effective_provider_context.clone();
for record in &bundle.model_switch_history {
let timeline_index = session.timeline.len() as u64;
session
.timeline
.push(crate::durable::TimelineEntry::ModelSwitched {
index: timeline_index,
from_model: record.from_model.clone(),
to_model: record.to_model.clone(),
from_provider: record.from_provider.clone(),
to_provider: record.to_provider.clone(),
adapted: record.adapted,
visible_id: None,
});
}
session
}
}
fn estimate_bundle_size(
compressed_blocks: &[CompressedBlock],
tail: &[StructuredMessage],
instructions: Option<&str>,
skill_state: &SessionSkillState,
) -> usize {
let mut total = 0usize;
if let Some(instr) = instructions {
total += (instr.len() as f64 * 0.25).ceil() as usize;
}
for block in compressed_blocks {
total += (block.render_to_text().len() as f64 * 0.25).ceil() as usize;
}
for msg in tail {
total += (msg.text_content().len() as f64 * 0.25).ceil() as usize;
}
let skill_instructions = skill_state.active_skill_instructions();
if !skill_instructions.is_empty() {
total += (skill_instructions.len() as f64 * 0.25).ceil() as usize;
}
total
}
fn is_local_resource(uri: &str) -> bool {
uri.starts_with("file://")
|| uri.starts_with("localhost")
|| uri.starts_with("127.0.0.1")
|| uri.starts_with("unix://")
|| uri.starts_with("/dev/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hydrate_into_new_produces_empty_plugin_enablement() {
let mut source = DurableSession::new(SessionId::new());
source.set_plugin_enabled("plugin-a", true);
source.set_plugin_enabled("plugin-b", false);
assert_eq!(
source.list_enabled_plugins(),
vec!["plugin-a".to_string()],
"sanity check: source should have plugin-a enabled"
);
let config = ContextManagementConfig::default();
let bundle = HandoffExporter::export(&source, "test-model", &[], vec![], &config, None)
.expect("export should succeed for idle session");
let hydrated = HandoffImporter::hydrate_into_new(bundle);
assert!(
hydrated.list_enabled_plugins().is_empty(),
"plugin enablement must not survive handoff; got {:?}",
hydrated.list_enabled_plugins()
);
assert_eq!(
hydrated.is_plugin_enabled("plugin-a"),
None,
"plugin-a must have no explicit enablement after hydration"
);
assert_eq!(
hydrated.is_plugin_enabled("plugin-b"),
None,
"plugin-b must have no explicit enablement after hydration"
);
}
#[test]
fn hydrate_in_place_does_not_modify_plugin_enablement() {
let mut source = DurableSession::new(SessionId::new());
source.set_plugin_enabled("plugin-x", true);
source.add_agent_text("hello");
let config = ContextManagementConfig::default();
let bundle = HandoffExporter::export(&source, "test-model", &[], vec![], &config, None)
.expect("export should succeed");
let mut target = DurableSession::new(SessionId::new());
target.set_plugin_enabled("plugin-y", true);
HandoffImporter::hydrate(&mut target, bundle).expect("hydrate should succeed");
assert_eq!(
target.is_plugin_enabled("plugin-x"),
None,
"source plugin enablement must not leak into target"
);
assert_eq!(
target.is_plugin_enabled("plugin-y"),
Some(true),
"target's own plugin enablement must be preserved"
);
}
#[test]
fn handoff_preserves_activated_skills() {
let mut source = DurableSession::new(SessionId::new());
source.activate_skill("test-skill", "# Test Skill\nDo something useful.", vec![]);
source.add_agent_text("hello");
assert!(source.skill_state.is_active("test-skill"));
let config = ContextManagementConfig::default();
let bundle = HandoffExporter::export(&source, "test-model", &[], vec![], &config, None)
.expect("export should succeed");
assert!(bundle.skill_state.is_active("test-skill"));
assert_eq!(bundle.skill_state.active.len(), 1);
assert_eq!(bundle.skill_state.active[0].name, "test-skill");
assert_eq!(
bundle.skill_state.active[0].body,
"# Test Skill\nDo something useful."
);
let hydrated = HandoffImporter::hydrate_into_new(bundle);
assert!(hydrated.skill_state.is_active("test-skill"));
assert_eq!(hydrated.skill_state.active.len(), 1);
assert_eq!(
hydrated.active_skill_instructions(),
"<skill_content name=\"test-skill\">\n# Test Skill\nDo something useful.\n</skill_content>"
);
}
#[test]
fn handoff_includes_skill_state_in_size_estimate() {
let mut source = DurableSession::new(SessionId::new());
source.activate_skill("big-skill", "x".repeat(1000), vec![]);
let config = ContextManagementConfig::default();
let bundle = HandoffExporter::export(&source, "test-model", &[], vec![], &config, None)
.expect("export should succeed");
assert!(
bundle.metadata.size_estimate_tokens >= 250,
"Size estimate should include skill instructions, got {} tokens",
bundle.metadata.size_estimate_tokens
);
}
}