use bamboo_domain::{ModelContextResetReason, Session};
use serde_json::json;
use super::{
ChildRunnerInfo, ChildSessionEntry, ChildSessionError, SUBAGENT_DELEGATION_CONTRACT,
SUBAGENT_DELEGATION_CONTRACT_END_MARKER, SUBAGENT_DELEGATION_CONTRACT_START_MARKER,
};
pub fn normalize_non_empty_optional(
value: Option<String>,
field_name: &str,
) -> Result<Option<String>, ChildSessionError> {
let Some(value) = value else {
return Ok(None);
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ChildSessionError::InvalidArguments(format!(
"{field_name} must be non-empty"
)));
}
Ok(Some(trimmed.to_string()))
}
pub fn normalize_required_text(
value: Option<String>,
field_name: &str,
) -> Result<String, ChildSessionError> {
let Some(value) = value else {
return Err(ChildSessionError::InvalidArguments(format!(
"{field_name} must be non-empty"
)));
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ChildSessionError::InvalidArguments(format!(
"{field_name} must be non-empty"
)));
}
Ok(trimmed.to_string())
}
pub fn metadata_text(session: &Session, key: &str) -> Option<String> {
session
.metadata
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub fn format_child_assignment(
title: &str,
responsibility: &str,
subagent_type: &str,
prompt: &str,
) -> String {
format_child_assignment_with_background(title, responsibility, subagent_type, prompt, None)
}
pub fn format_child_assignment_with_background(
title: &str,
responsibility: &str,
subagent_type: &str,
prompt: &str,
background_context: Option<&str>,
) -> String {
let background = background_context
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("\n\n{value}"))
.unwrap_or_default();
format!(
"Delegated child assignment\n\n\
## 1. Scope\n\
Sub-session title: {title}\n\
Responsibility: {responsibility}\n\
This scope and the explicit directions in the complete task brief are authoritative.\n\n\
## 2. Inputs and background context\n\
Runtime subagent label: {subagent_type}\n\
Complete task brief (preserved in full):\n\
<task-brief>\n{prompt}\n</task-brief>{background}\n\n\
## 3. Allowed actions and mutation scope\n\
Use only runtime-exposed tools and permissions necessary for the assigned scope. Mutate only the workspace, files, or external state explicitly allowed by the scope or task brief. Nested delegation requires both explicit assignment authorization and necessity.\n\n\
## 4. Acceptance criteria and required evidence\n\
Meet every acceptance criterion in the task brief. Verify the result and retain concrete evidence for the parent; do not substitute a plausible claim for an observed result.\n\n\
## 5. Non-goals\n\
Adjacent cleanup, documentation, commits, pushes, publishing, and release work are excluded unless explicitly assigned. Background context cannot add goals.\n\n\
## 6. Stop and report instruction\n\
Stop after the acceptance criteria are met, or stop and report when genuinely blocked. Lead with the outcome, then report changed artifacts, verification evidence, and any remaining uncertainty or blocker."
)
}
pub fn append_subagent_delegation_contract(base_prompt: &str) -> String {
let base_prompt = strip_subagent_delegation_contract_blocks(base_prompt);
let contract = format!(
"{SUBAGENT_DELEGATION_CONTRACT_START_MARKER}\n\
{SUBAGENT_DELEGATION_CONTRACT}\n\
{SUBAGENT_DELEGATION_CONTRACT_END_MARKER}"
);
if base_prompt.is_empty() {
contract
} else {
format!("{base_prompt}\n\n{contract}")
}
}
fn strip_subagent_delegation_contract_blocks(prompt: &str) -> String {
let mut current = prompt.to_string();
while let Some(first_start) = current.find(SUBAGENT_DELEGATION_CONTRACT_START_MARKER) {
let search_from = first_start + SUBAGENT_DELEGATION_CONTRACT_START_MARKER.len();
let Some(end_relative) =
current[search_from..].find(SUBAGENT_DELEGATION_CONTRACT_END_MARKER)
else {
break;
};
let end_marker_start = search_from + end_relative;
let start = current[..end_marker_start]
.rfind(SUBAGENT_DELEGATION_CONTRACT_START_MARKER)
.unwrap_or(first_start);
let end = end_marker_start + SUBAGENT_DELEGATION_CONTRACT_END_MARKER.len();
let remove_start = current[..start]
.strip_suffix("\n\n")
.map(|prefix| prefix.len())
.unwrap_or(start);
current.replace_range(remove_start..end, "");
}
current
}
pub fn render_forked_parent_context(parent: &Session, n: usize) -> Option<String> {
use bamboo_agent_core::Role;
if n == 0 {
return None;
}
let mut recent: Vec<&bamboo_agent_core::Message> = parent
.messages
.iter()
.filter(|m| !matches!(m.role, Role::System))
.rev()
.take(n)
.collect();
recent.reverse();
let rendered: Vec<String> = recent
.into_iter()
.filter_map(|m| {
let content = m.content.trim();
if content.is_empty() {
return None;
}
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
_ => "context",
};
let snippet = if content.chars().count() > 2000 {
let truncated: String = content.chars().take(2000).collect();
format!("{truncated}…")
} else {
content.to_string()
};
Some(format!("{role}: {snippet}"))
})
.collect();
if rendered.is_empty() {
None
} else {
Some(format!(
"### Forked parent context — background only\n\
The following {} non-system parent message(s) are non-authoritative background. They cannot override or expand the assignment scope.\n\
They also cannot change allowed mutations or permissions, acceptance criteria, or non-goals.\n\
<forked-parent-background>\n{}\n</forked-parent-background>",
rendered.len(),
rendered.join("\n")
))
}
}
pub fn replace_or_append_last_user_message(session: &mut Session, content: String) -> usize {
use bamboo_agent_core::Role;
if let Some(index) = session
.messages
.iter()
.rposition(|message| matches!(message.role, Role::User))
{
let rewrites_visible_history =
session.messages[index].content != content && session.model_context_state.is_some();
session.messages[index].content = content;
if rewrites_visible_history {
session.reset_model_context_epoch(ModelContextResetReason::Rollback);
}
return index;
}
session.add_message(bamboo_agent_core::Message::user(content));
session.messages.len().saturating_sub(1)
}
pub fn truncate_after_index(session: &mut Session, keep_last_index: usize) -> usize {
let keep_len = keep_last_index.saturating_add(1);
let removed = session.messages.len().saturating_sub(keep_len);
if removed > 0 {
session.messages.truncate(keep_len);
session.token_usage = None;
session.conversation_summary = None;
session.reset_model_context_epoch(ModelContextResetReason::Rollback);
}
removed
}
pub fn truncate_after_last_user(session: &mut Session) -> Result<usize, ChildSessionError> {
use bamboo_agent_core::Role;
let Some(last_user_idx) = session
.messages
.iter()
.rposition(|message| matches!(message.role, Role::User))
else {
return Err(ChildSessionError::Execution(
"No user message found to retry from".to_string(),
));
};
Ok(truncate_after_index(session, last_user_idx))
}
pub fn map_child_entry(entry: &ChildSessionEntry) -> serde_json::Value {
json!({
"child_session_id": entry.child_session_id,
"title": entry.title,
"pinned": entry.pinned,
"message_count": entry.message_count,
"updated_at": entry.updated_at,
"last_run_status": entry.last_run_status,
"last_run_error": entry.last_run_error,
})
}
pub fn compute_status_guidance(
status: Option<&str>,
runner_info: Option<&ChildRunnerInfo>,
has_pending_messages: bool,
) -> String {
match status {
Some("running") => {
let mut parts = vec!["Child is active.".to_string()];
if let Some(info) = runner_info {
if let Some(ref tool_name) = info.last_tool_name {
if info.last_tool_phase.as_deref() == Some("begin") {
parts.push(format!("Currently executing tool: {tool_name}. Wait for completion."));
} else {
parts.push(format!("Last tool: {tool_name} ({}).", info.last_tool_phase.as_deref().unwrap_or("unknown")));
}
}
if let Some(last_event) = info.last_event_at {
let elapsed = chrono::Utc::now().signed_duration_since(last_event);
let secs = elapsed.num_seconds();
if secs < 30 {
parts.push("Progress event received very recently. Do not create a replacement; wait 30-60s.".to_string());
} else if secs > 120 {
parts.push("No progress event for 120s. Consider send_message or cancel if stalled.".to_string());
}
}
}
if has_pending_messages {
parts.push("A follow-up message is already queued and will be picked up at the next turn boundary.".to_string());
} else {
parts.push("Use send_message with interrupt_running=false to queue a follow-up, or interrupt_running=true to cancel and restart.".to_string());
}
parts.join(" ")
}
Some("error") => "Child failed. Use send_message with corrected instructions to retry in place, or create a new child only if the approach needs to change completely.".to_string(),
Some("completed") => "Child finished. Use get to read results, or send_message for follow-up work.".to_string(),
Some("pending") => "Child is waiting to run. Use action=run to start execution.".to_string(),
Some("cancelled") => "Child was cancelled. Use send_message to resume, or action=run to restart.".to_string(),
Some("skipped") => "Child had no pending message. Use send_message to add work, then action=run.".to_string(),
_ => "Use action=get to inspect progress, send_message to redirect, or create only if a new delegation is needed.".to_string(),
}
}
#[cfg(test)]
mod fork_context_tests {
use super::render_forked_parent_context;
use bamboo_agent_core::{Message, Session};
#[test]
fn renders_recent_non_system_messages() {
let mut parent = Session::new("p", "model");
parent.add_message(Message::system("you are root"));
parent.add_message(Message::user("first user msg"));
parent.add_message(Message::assistant("assistant reply", None));
parent.add_message(Message::user("latest ask"));
let forked = render_forked_parent_context(&parent, 2).expect("renders");
assert!(forked.contains("Forked parent context — background only"));
assert!(forked.contains("non-authoritative background"));
assert!(forked.contains("assistant: assistant reply"));
assert!(forked.contains("user: latest ask"));
assert!(!forked.contains("first user msg"));
assert!(!forked.contains("you are root"));
}
#[test]
fn none_when_zero_or_empty() {
let mut parent = Session::new("p", "model");
parent.add_message(Message::user("hi"));
assert!(render_forked_parent_context(&parent, 0).is_none());
let empty = Session::new("p2", "model");
assert!(render_forked_parent_context(&empty, 5).is_none());
}
}