bamboo_engine/session_app/child_session/
helpers.rs1use bamboo_domain::Session;
4use serde_json::json;
5
6use super::{ChildRunnerInfo, ChildSessionEntry, ChildSessionError};
7
8pub fn normalize_non_empty_optional(
9 value: Option<String>,
10 field_name: &str,
11) -> Result<Option<String>, ChildSessionError> {
12 let Some(value) = value else {
13 return Ok(None);
14 };
15 let trimmed = value.trim();
16 if trimmed.is_empty() {
17 return Err(ChildSessionError::InvalidArguments(format!(
18 "{field_name} must be non-empty"
19 )));
20 }
21 Ok(Some(trimmed.to_string()))
22}
23
24pub fn normalize_required_text(
25 value: Option<String>,
26 field_name: &str,
27) -> Result<String, ChildSessionError> {
28 let Some(value) = value else {
29 return Err(ChildSessionError::InvalidArguments(format!(
30 "{field_name} must be non-empty"
31 )));
32 };
33 let trimmed = value.trim();
34 if trimmed.is_empty() {
35 return Err(ChildSessionError::InvalidArguments(format!(
36 "{field_name} must be non-empty"
37 )));
38 }
39 Ok(trimmed.to_string())
40}
41
42pub fn metadata_text(session: &Session, key: &str) -> Option<String> {
43 session
44 .metadata
45 .get(key)
46 .map(|value| value.trim())
47 .filter(|value| !value.is_empty())
48 .map(str::to_string)
49}
50
51pub fn format_child_assignment(
52 title: &str,
53 responsibility: &str,
54 subagent_type: &str,
55 prompt: &str,
56) -> String {
57 format!(
58 "Sub-session title: {}\nResponsibility: {}\nSubagent type: {}\n\nTask brief:\n{}",
59 title, responsibility, subagent_type, prompt
60 )
61}
62
63pub fn render_forked_parent_context(parent: &Session, n: usize) -> Option<String> {
70 use bamboo_agent_core::Role;
71 if n == 0 {
72 return None;
73 }
74 let mut recent: Vec<&bamboo_agent_core::Message> = parent
75 .messages
76 .iter()
77 .filter(|m| !matches!(m.role, Role::System))
78 .rev()
79 .take(n)
80 .collect();
81 recent.reverse();
82
83 let rendered: Vec<String> = recent
84 .into_iter()
85 .filter_map(|m| {
86 let content = m.content.trim();
87 if content.is_empty() {
88 return None;
89 }
90 let role = match m.role {
91 Role::User => "user",
92 Role::Assistant => "assistant",
93 _ => "context",
94 };
95 let snippet = if content.chars().count() > 2000 {
96 let truncated: String = content.chars().take(2000).collect();
97 format!("{truncated}…")
98 } else {
99 content.to_string()
100 };
101 Some(format!("{role}: {snippet}"))
102 })
103 .collect();
104
105 if rendered.is_empty() {
106 None
107 } else {
108 Some(format!(
109 "## Forked context from parent (last {} message(s))\n{}",
110 rendered.len(),
111 rendered.join("\n")
112 ))
113 }
114}
115
116#[cfg(test)]
117mod fork_context_tests {
118 use super::render_forked_parent_context;
119 use bamboo_agent_core::{Message, Session};
120
121 #[test]
122 fn renders_recent_non_system_messages() {
123 let mut parent = Session::new("p", "model");
124 parent.add_message(Message::system("you are root"));
125 parent.add_message(Message::user("first user msg"));
126 parent.add_message(Message::assistant("assistant reply", None));
127 parent.add_message(Message::user("latest ask"));
128
129 let forked = render_forked_parent_context(&parent, 2).expect("renders");
130 assert!(forked.contains("Forked context from parent"));
131 assert!(forked.contains("assistant: assistant reply"));
133 assert!(forked.contains("user: latest ask"));
134 assert!(!forked.contains("first user msg"));
136 assert!(!forked.contains("you are root"));
137 }
138
139 #[test]
140 fn none_when_zero_or_empty() {
141 let mut parent = Session::new("p", "model");
142 parent.add_message(Message::user("hi"));
143 assert!(render_forked_parent_context(&parent, 0).is_none());
144 let empty = Session::new("p2", "model");
145 assert!(render_forked_parent_context(&empty, 5).is_none());
146 }
147}
148
149pub fn replace_or_append_last_user_message(session: &mut Session, content: String) -> usize {
150 use bamboo_agent_core::Role;
151
152 if let Some(index) = session
153 .messages
154 .iter()
155 .rposition(|message| matches!(message.role, Role::User))
156 {
157 session.messages[index].content = content;
158 return index;
159 }
160
161 session.add_message(bamboo_agent_core::Message::user(content));
162 session.messages.len().saturating_sub(1)
163}
164
165pub fn truncate_after_index(session: &mut Session, keep_last_index: usize) -> usize {
166 let keep_len = keep_last_index.saturating_add(1);
167 let removed = session.messages.len().saturating_sub(keep_len);
168 if removed > 0 {
169 session.messages.truncate(keep_len);
170 session.token_usage = None;
171 session.conversation_summary = None;
172 }
173 removed
174}
175
176pub fn truncate_after_last_user(session: &mut Session) -> Result<usize, ChildSessionError> {
177 use bamboo_agent_core::Role;
178
179 let Some(last_user_idx) = session
180 .messages
181 .iter()
182 .rposition(|message| matches!(message.role, Role::User))
183 else {
184 return Err(ChildSessionError::Execution(
185 "No user message found to retry from".to_string(),
186 ));
187 };
188
189 Ok(truncate_after_index(session, last_user_idx))
190}
191
192pub fn map_child_entry(entry: &ChildSessionEntry) -> serde_json::Value {
193 json!({
194 "child_session_id": entry.child_session_id,
195 "title": entry.title,
196 "pinned": entry.pinned,
197 "message_count": entry.message_count,
198 "updated_at": entry.updated_at,
199 "last_run_status": entry.last_run_status,
200 "last_run_error": entry.last_run_error,
201 })
202}
203
204pub fn compute_status_guidance(
206 status: Option<&str>,
207 runner_info: Option<&ChildRunnerInfo>,
208 has_pending_messages: bool,
209) -> String {
210 match status {
211 Some("running") => {
212 let mut parts = vec!["Child is active.".to_string()];
213 if let Some(info) = runner_info {
214 if let Some(ref tool_name) = info.last_tool_name {
215 if info.last_tool_phase.as_deref() == Some("begin") {
216 parts.push(format!("Currently executing tool: {tool_name}. Wait for completion."));
217 } else {
218 parts.push(format!("Last tool: {tool_name} ({}).", info.last_tool_phase.as_deref().unwrap_or("unknown")));
219 }
220 }
221 if let Some(last_event) = info.last_event_at {
222 let elapsed = chrono::Utc::now().signed_duration_since(last_event);
223 let secs = elapsed.num_seconds();
224 if secs < 30 {
225 parts.push("Progress event received very recently. Do not create a replacement; wait 30-60s.".to_string());
226 } else if secs > 120 {
227 parts.push("No progress event for 120s. Consider send_message or cancel if stalled.".to_string());
228 }
229 }
230 }
231 if has_pending_messages {
232 parts.push("A follow-up message is already queued and will be picked up at the next turn boundary.".to_string());
233 } else {
234 parts.push("Use send_message with interrupt_running=false to queue a follow-up, or interrupt_running=true to cancel and restart.".to_string());
235 }
236 parts.join(" ")
237 }
238 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(),
239 Some("completed") => "Child finished. Use get to read results, or send_message for follow-up work.".to_string(),
240 Some("pending") => "Child is waiting to run. Use action=run to start execution.".to_string(),
241 Some("cancelled") => "Child was cancelled. Use send_message to resume, or action=run to restart.".to_string(),
242 Some("skipped") => "Child had no pending message. Use send_message to add work, then action=run.".to_string(),
243 _ => "Use action=get to inspect progress, send_message to redirect, or create only if a new delegation is needed.".to_string(),
244 }
245}