bamboo_engine/runtime/execution/
spawn.rs1use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use chrono::Utc;
12use tokio::sync::{broadcast, mpsc, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::tools::ToolExecutor;
16use bamboo_agent_core::{AgentEvent, Session};
17use bamboo_llm::ProviderModelRouter;
18
19use crate::runtime::Agent;
20
21use super::child_completion::{ChildCompletion, ChildCompletionHandler};
22use super::runner_state::AgentRunner;
23
24#[derive(Debug, Clone)]
25pub struct SpawnJob {
26 pub parent_session_id: String,
27 pub child_session_id: String,
28 pub model: String,
29 pub disabled_tools: Option<Vec<String>>,
32}
33
34#[async_trait::async_trait]
39pub trait ExternalChildRunner: Send + Sync {
40 async fn should_handle(&self, session: &Session) -> bool;
42
43 async fn execute_external_child(
45 &self,
46 session: &mut Session,
47 job: &SpawnJob,
48 event_tx: tokio::sync::mpsc::Sender<AgentEvent>,
49 cancel_token: CancellationToken,
50 ) -> crate::runtime::runner::Result<()>;
51
52 fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
59}
60
61#[derive(Clone)]
62pub struct SpawnContext {
63 pub agent: Arc<Agent>,
64 pub tools: Arc<dyn ToolExecutor>,
65 pub sessions_cache: crate::SessionCache,
66 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
67 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
68 pub external_child_runner: Arc<dyn ExternalChildRunner>,
69 pub provider_router: Option<Arc<ProviderModelRouter>>,
70 pub app_data_dir: Option<std::path::PathBuf>,
71 pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
76 pub account_feed_inbox: Option<super::event_forwarder::AccountFeedInbox>,
80}
81
82#[derive(Clone)]
83pub struct SpawnScheduler {
84 tx: mpsc::Sender<SpawnJob>,
85}
86
87impl SpawnScheduler {
88 pub fn new(ctx: SpawnContext) -> Self {
89 let (tx, mut rx) = mpsc::channel::<SpawnJob>(128);
90
91 tokio::spawn(async move {
99 while let Some(job) = rx.recv().await {
100 let job_ctx = ctx.clone();
101 let job_for_panic = job.clone();
102 let handle = tokio::spawn(async move {
103 if let Err(err) = run_spawn_job(job_ctx, job).await {
104 tracing::warn!("spawn job failed: {}", err);
105 }
106 });
107 if let Err(join_error) = handle.await {
108 tracing::error!(
109 parent_session_id = %job_for_panic.parent_session_id,
110 child_session_id = %job_for_panic.child_session_id,
111 error = %join_error,
112 "spawn job panicked; publishing terminal error completion"
113 );
114 let parent_tx = super::session_events::get_or_create_event_sender(
115 &ctx.session_event_senders,
116 &job_for_panic.parent_session_id,
117 )
118 .await;
119 publish_child_completion_parts(
120 &parent_tx,
121 ctx.completion_handler.clone(),
122 job_for_panic.parent_session_id.clone(),
123 job_for_panic.child_session_id.clone(),
124 "error".to_string(),
125 Some(format!("child spawn panicked: {join_error}")),
126 )
127 .await;
128 }
129 }
130 });
131
132 Self { tx }
133 }
134
135 pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
136 self.tx
137 .send(job)
138 .await
139 .map_err(|_| "spawn scheduler is not running".to_string())
140 }
141}
142
143#[derive(Debug, Clone, Copy)]
144pub(crate) struct ChildWatchdogPolicy {
145 check_interval_secs: i64,
146 pub(crate) max_total_secs: i64,
150 pub(crate) max_idle_secs: i64,
151}
152
153impl Default for ChildWatchdogPolicy {
154 fn default() -> Self {
155 Self {
156 check_interval_secs: 15,
157 max_total_secs: 60 * 60,
161 max_idle_secs: 15 * 60,
163 }
164 }
165}
166
167fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
168 session
169 .metadata
170 .get(key)
171 .and_then(|value| value.trim().parse::<i64>().ok())
172 .filter(|value| *value > 0)
173}
174
175pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
176 let mut policy = ChildWatchdogPolicy::default();
177 if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
178 policy.max_total_secs = value;
179 }
180 if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
181 policy.max_idle_secs = value;
182 }
183 if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
184 policy.check_interval_secs = value;
185 }
186 policy
187}
188
189async fn publish_child_completion(
190 parent_tx: &broadcast::Sender<AgentEvent>,
191 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
192 completion: ChildCompletion,
193) {
194 let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
195 parent_session_id: completion.parent_session_id.clone(),
196 child_session_id: completion.child_session_id.clone(),
197 status: completion.status.clone(),
198 error: completion.error.clone(),
199 });
200
201 if let Some(handler) = completion_handler {
202 use futures::FutureExt;
209 let parent_session_id = completion.parent_session_id.clone();
210 let child_session_id = completion.child_session_id.clone();
211 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
212 .catch_unwind()
213 .await
214 .is_err()
215 {
216 tracing::error!(
217 %parent_session_id,
218 %child_session_id,
219 "child completion handler panicked; child-wait watchdog will backstop the parent wake"
220 );
221 }
222 }
223}
224
225pub(crate) async fn publish_child_completion_parts(
226 parent_tx: &broadcast::Sender<AgentEvent>,
227 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
228 parent_session_id: String,
229 child_session_id: String,
230 status: String,
231 error: Option<String>,
232) {
233 publish_child_completion(
234 parent_tx,
235 completion_handler,
236 ChildCompletion {
237 parent_session_id,
238 child_session_id,
239 status,
240 error,
241 completed_at: Utc::now(),
242 },
243 )
244 .await;
245}
246
247pub(crate) async fn watch_child_liveness(
248 parent_session_id: String,
249 child_session_id: String,
250 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
251 cancel_token: CancellationToken,
252 timeout_reason: Arc<RwLock<Option<String>>>,
253 done: CancellationToken,
254 policy: ChildWatchdogPolicy,
255) {
256 let mut ticker =
257 tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
258 ticker.tick().await;
260
261 loop {
262 tokio::select! {
263 _ = done.cancelled() => return,
264 _ = ticker.tick() => {
265 if cancel_token.is_cancelled() {
266 return;
267 }
268
269 let snapshot = {
270 let guard = runners.read().await;
271 guard.get(&child_session_id).cloned()
272 };
273 let Some(runner) = snapshot else {
274 return;
275 };
276 if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
277 return;
278 }
279
280 let now = Utc::now();
281 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
282 if total_secs >= policy.max_total_secs {
283 let reason = format!(
284 "Child session timed out after {} seconds (max_total_secs={})",
285 total_secs, policy.max_total_secs
286 );
287 tracing::warn!(
288 parent_session_id = %parent_session_id,
289 child_session_id = %child_session_id,
290 reason = %reason,
291 "child session total timeout; cancelling child runner"
292 );
293 *timeout_reason.write().await = Some(reason);
294 cancel_token.cancel();
295 return;
296 }
297
298 let last_activity_at = runner.last_event_at.unwrap_or(runner.started_at);
299 let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
300 if idle_secs >= policy.max_idle_secs {
301 let reason = format!(
302 "Child session idle timeout after {} seconds without events (max_idle_secs={})",
303 idle_secs, policy.max_idle_secs
304 );
305 tracing::warn!(
306 parent_session_id = %parent_session_id,
307 child_session_id = %child_session_id,
308 reason = %reason,
309 last_tool_name = ?runner.last_tool_name,
310 last_tool_phase = ?runner.last_tool_phase,
311 round_count = runner.round_count,
312 "child session idle timeout; cancelling child runner"
313 );
314 *timeout_reason.write().await = Some(reason);
315 cancel_token.cancel();
316 return;
317 }
318 }
319 }
320 }
321}
322
323async fn run_spawn_job(ctx: SpawnContext, job: SpawnJob) -> Result<(), String> {
330 crate::sdk::spawn::run_child_spawn(ctx, job).await
331}