1use std::path::{Path, PathBuf};
4use std::pin::Pin;
5use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
6use std::sync::{Arc, Mutex, Weak};
7use std::task::{Context as PollContext, Poll};
8use std::time::{Duration, Instant};
9
10use futures::stream::{BoxStream, empty, once};
11use futures::{Stream, StreamExt};
12use oino_ai::{AgentMessage, JsonValue, Model, ThinkingLevel};
13use oino_session::{CreateOptions, SessionLease, SessionManager};
14use serde_json::json;
15use tokio::task::JoinHandle;
16
17use super::spec::{
18 AgentSpec, CLOSE_GRACE_CAP, DEFAULT_HIBERNATE_AFTER_SEC, DEFAULT_MAX_CONCURRENT,
19 DEFAULT_MAX_DEPTH, ExtensionPolicy, HARD_MAX_CONCURRENT, HARD_MAX_DEPTH, Persistence, Seed,
20};
21use super::{ChildConfig, ChildLink, Tree};
22use crate::builtin::{SessionToolContext, builtin_tool_entries};
23use crate::events::{AgentSessionEvent, ClientEvent, OinoEvent, RunState};
24use crate::extensions::api::CallContext;
25use crate::extensions::host::HostError;
26use crate::extensions::runner::ExtensionSet;
27use crate::runtime::{acquire_lease, process_lease_info};
28use crate::session::{
29 AgentSession, Inner, InputSource, IsolatedTask, PromptDisposition, PromptOptions,
30 SessionConfig, StreamingBehavior, lock,
31};
32use oino_session::LeaseInfo;
33
34const HIBERNATE_POLL: Duration = Duration::from_millis(500);
36
37fn failed(error: impl std::fmt::Display) -> HostError {
38 HostError::Failed(error.to_string())
39}
40
41pub struct ChildAgent {
48 id: String,
49 label: String,
50 owner: String,
51 session: Mutex<Option<AgentSession>>,
53 parent: Weak<Inner>,
54 tree: Arc<Tree>,
55 closed: AtomicBool,
56 forward: Mutex<Option<JoinHandle<()>>>,
57 lease: Mutex<Option<SessionLease>>,
60 lease_epoch: AtomicU64,
62 gate: tokio::sync::Mutex<()>,
65 spec: AgentSpec,
67 model: Model,
69 thinking: Mutex<ThinkingLevel>,
71 cwd: PathBuf,
73 depth: u8,
75 role: Option<String>,
77 file: Option<PathBuf>,
79 last_usage: Mutex<Option<JsonValue>>,
81 last_text: Mutex<Option<String>>,
82 events_clients: Arc<AtomicUsize>,
84 idle_since: Mutex<Option<Instant>>,
86 watch_self: std::sync::OnceLock<Weak<ChildAgent>>,
88}
89
90impl std::fmt::Debug for ChildAgent {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("ChildAgent")
93 .field("id", &self.id)
94 .field("label", &self.label)
95 .finish_non_exhaustive()
96 }
97}
98
99struct EventsGuard(Arc<AtomicUsize>);
101
102impl Drop for EventsGuard {
103 fn drop(&mut self) {
104 self.0.fetch_sub(1, Ordering::SeqCst);
105 }
106}
107
108struct CountedEvents {
110 inner: BoxStream<'static, JsonValue>,
111 _live: EventsGuard,
112}
113
114impl Stream for CountedEvents {
115 type Item = JsonValue;
116 fn poll_next(mut self: Pin<&mut Self>, cx: &mut PollContext<'_>) -> Poll<Option<Self::Item>> {
117 self.inner.as_mut().poll_next(cx)
118 }
119}
120
121fn event_json(event: impl serde::Serialize) -> JsonValue {
122 serde_json::to_value(&event).unwrap_or(JsonValue::Null)
123}
124
125impl ChildAgent {
126 #[must_use]
128 pub fn id(&self) -> &str {
129 &self.id
130 }
131
132 #[must_use]
134 pub fn label(&self) -> &str {
135 &self.label
136 }
137
138 #[must_use]
140 pub fn owner(&self) -> &str {
141 &self.owner
142 }
143
144 #[must_use]
147 pub fn session(&self) -> Option<AgentSession> {
148 lock(&self.session).clone()
149 }
150
151 fn session_slot(&self) -> Option<AgentSession> {
153 lock(&self.session).clone()
154 }
155
156 #[must_use]
159 pub fn messages(&self) -> Vec<AgentMessage> {
160 self.session_slot()
161 .map(|session| session.messages())
162 .unwrap_or_default()
163 }
164
165 #[must_use]
167 pub fn session_file(&self) -> Option<PathBuf> {
168 match self.session_slot() {
169 Some(session) => session.session_file(),
170 None => self.file.clone(),
171 }
172 }
173
174 #[must_use]
176 pub fn is_closed(&self) -> bool {
177 self.closed.load(Ordering::SeqCst)
178 }
179
180 #[must_use]
183 pub fn is_hibernated(&self) -> bool {
184 !self.is_closed() && self.session_slot().is_none()
185 }
186
187 #[must_use]
189 pub fn state(&self) -> &'static str {
190 if self.is_closed() {
191 "closed"
192 } else if let Some(session) = self.session_slot() {
193 if session.is_idle() { "idle" } else { "running" }
194 } else {
195 "hibernated"
196 }
197 }
198
199 #[must_use]
201 pub(crate) fn depth(&self) -> u8 {
202 self.depth
203 }
204
205 #[must_use]
207 pub(crate) fn role(&self) -> Option<&str> {
208 self.role.as_deref()
209 }
210
211 fn ensure_open(&self) -> Result<(), HostError> {
212 if self.is_closed() {
213 Err(HostError::Failed(format!("Agent {} is closed", self.id)))
214 } else {
215 Ok(())
216 }
217 }
218
219 async fn session_or_wake(&self) -> Result<AgentSession, HostError> {
222 let _gate = self.gate.lock().await;
223 self.session_or_wake_locked().await
224 }
225
226 async fn session_or_wake_locked(&self) -> Result<AgentSession, HostError> {
228 if let Some(session) = self.session_slot() {
229 return Ok(session);
230 }
231 self.wake_locked().await
232 }
233
234 async fn wake_locked(&self) -> Result<AgentSession, HostError> {
238 if self.is_closed() {
239 return Err(HostError::Failed(format!("Agent {} is closed", self.id)));
240 }
241 let Some(parent_inner) = self.parent.upgrade() else {
242 return Err(HostError::Failed(format!(
243 "Agent {} cannot wake: its parent session is gone",
244 self.id
245 )));
246 };
247 let parent = AgentSession::from_inner(parent_inner);
248 let Some(file) = self.file.clone() else {
249 return Err(HostError::Failed(format!(
250 "Agent {} has no session file to wake from",
251 self.id
252 )));
253 };
254 let mut spec = self.spec.clone();
255 spec.seed = Seed::SessionFile(file);
256 let thinking = *lock(&self.thinking);
257 let built = parent
258 .build_child(
259 &self.owner,
260 &spec,
261 self.model.clone(),
262 thinking,
263 self.cwd.as_path(),
264 self.depth,
265 ChildOpen::Wake,
266 )
267 .await;
268 let (session, _lease_replaced_none, _file) = match built {
269 Ok(built) => built,
270 Err(error) => {
271 tracing::warn!(session = %self.id, "subagent wake failed: {error}");
272 return Err(error);
273 }
274 };
275 if session.session_id() != self.id {
276 return Err(HostError::Failed(format!(
277 "Agent {} woke to a different session ({})",
278 self.id,
279 session.session_id()
280 )));
281 }
282 if self.is_closed() {
283 session.dispose().await;
285 return Err(HostError::Failed(format!("Agent {} is closed", self.id)));
286 }
287 if let Some(router) = parent.inner.agents.router() {
288 router.attach(&session);
289 }
290 session.bind_extensions("resume", None).await;
291 let forward = parent.spawn_isolated(
292 IsolatedTask::ChildEvents,
293 forward_child_events(
294 session.subscribe(),
295 Arc::downgrade(&parent.inner),
296 session.inner.agents.link.clone(),
297 self.owner.clone(),
298 session.session_id(),
299 parent.runner().call_context().clone(),
300 ),
301 );
302 *lock(&self.forward) = Some(forward);
303 *lock(&self.session) = Some(session.clone());
304 *lock(&self.idle_since) = None;
305 if let Some(me) = self.watch_self.get().and_then(Weak::upgrade) {
306 tokio::spawn(hibernate_watcher(Arc::downgrade(&me)));
307 }
308 tracing::info!(session = %self.id, "subagent woke from hibernation (E-HIBERNATE)");
309 parent.publish_agents_changed();
310 Ok(session)
311 }
312
313 async fn hibernate(&self) {
318 let _gate = self.gate.lock().await;
319 if self.is_closed() {
320 return;
321 }
322 let Some(session) = lock(&self.session).take() else {
323 return; };
325 if !Self::hibernatable(&session, &self.events_clients) {
326 *lock(&self.session) = Some(session);
327 return;
328 }
329 *lock(&self.last_usage) = Some(event_json(session.session_stats()));
331 *lock(&self.last_text) = session.last_assistant_text();
332 let runner = session.runner();
333 if runner.has_handlers("session_shutdown") {
334 runner
335 .emit(json!({"type": "session_shutdown", "reason": "hibernate"}))
336 .await;
337 }
338 if let Err(error) = session.with_manager(SessionManager::sync) {
342 tracing::warn!(session = %self.id, "hibernate fsync failed: {error}");
343 }
344 if let Some(router) = session.inner.agents.router() {
345 router.detach(&self.id);
346 }
347 session.dispose().await;
348 session.hub().close();
349 if let Some(task) = lock(&self.forward).take() {
350 task.abort();
351 }
352 *lock(&self.idle_since) = None;
353 tracing::info!(session = %self.id, "subagent hibernated (E-HIBERNATE)");
354 if let Some(parent) = self.parent.upgrade() {
355 AgentSession::from_inner(parent).publish_agents_changed();
356 }
357 }
358
359 fn hibernatable(session: &AgentSession, events_clients: &AtomicUsize) -> bool {
363 session.session_file().is_some()
364 && session.run_state() == RunState::Idle
365 && !session.is_bash_running()
366 && !session
367 .ui()
368 .snapshot()
369 .iter()
370 .any(|request| crate::ui::is_dialog(&request.method))
371 && events_clients.load(Ordering::SeqCst) == 0
372 }
373
374 pub async fn prompt(&self, text: String, delivery: Option<&str>) -> Result<String, HostError> {
382 self.ensure_open()?;
383 let behavior = if delivery == Some("steer") {
384 StreamingBehavior::Steer
385 } else {
386 StreamingBehavior::FollowUp
387 };
388 let _gate = self.gate.lock().await;
389 let session = self.session_or_wake_locked().await?;
390 let disposition = session
391 .prompt(
392 text,
393 PromptOptions {
394 streaming_behavior: Some(behavior),
395 source: InputSource::Extension,
396 ..PromptOptions::default()
397 },
398 )
399 .await
400 .map_err(failed)?;
401 Ok(match disposition {
402 PromptDisposition::Handled => "handled",
403 PromptDisposition::Queued => "queued",
404 PromptDisposition::Started => "started",
405 }
406 .to_owned())
407 }
408
409 pub async fn steer(&self, text: String) -> Result<(), HostError> {
415 self.prompt(text, Some("steer")).await.map(|_| ())
416 }
417
418 pub async fn follow_up(&self, text: String) -> Result<(), HostError> {
424 self.prompt(text, Some("followUp")).await.map(|_| ())
425 }
426
427 pub fn abort(&self) {
430 if let Some(session) = self.session_slot() {
431 tokio::spawn(async move { session.abort().await });
432 }
433 }
434
435 pub fn set_thinking(&self, level: &str) -> Result<String, HostError> {
442 let level: ThinkingLevel = level
443 .parse()
444 .map_err(|_| HostError::Invalid(format!("thinking level `{level}`")))?;
445 if let Some(session) = self.session_slot() {
446 let clamped = level.clamp(&session.available_thinking_levels());
447 *lock(&self.thinking) = clamped;
448 tokio::spawn(async move {
449 session.set_thinking_level(clamped, false).await;
450 });
451 Ok(clamped.to_string())
452 } else {
453 let clamped = level.clamp(&self.model.available_thinking_levels());
456 *lock(&self.thinking) = clamped;
457 Ok(clamped.to_string())
458 }
459 }
460
461 #[must_use]
463 pub fn last_assistant_text(&self) -> Option<String> {
464 match self.session_slot() {
465 Some(session) => session.last_assistant_text(),
466 None => lock(&self.last_text).clone(),
467 }
468 }
469
470 pub async fn wait_idle(&self) {
472 if let Some(session) = self.session_slot() {
473 session.wait_for_idle().await;
474 }
475 }
476
477 #[must_use]
480 pub fn usage(&self) -> JsonValue {
481 match self.session_slot() {
482 Some(session) => event_json(session.session_stats()),
483 None => lock(&self.last_usage).clone().unwrap_or(JsonValue::Null),
484 }
485 }
486
487 pub fn events(&self) -> BoxStream<'static, JsonValue> {
491 if self.is_closed() {
492 return Box::pin(empty());
493 }
494 let count = Arc::clone(&self.events_clients);
495 count.fetch_add(1, Ordering::SeqCst);
496 let me = self.watch_self.get().cloned();
497 let inner: BoxStream<'static, JsonValue> = match self.session_slot() {
498 Some(session) => Box::pin(session.subscribe().map(event_json)),
499 None => Box::pin(
500 once(async move {
501 let Some(me) = me.as_ref().and_then(Weak::upgrade) else {
502 return empty::<JsonValue>().boxed();
503 };
504 match me.session_or_wake().await {
505 Ok(session) => session.subscribe().map(event_json).boxed(),
506 Err(_) => empty::<JsonValue>().boxed(),
507 }
508 })
509 .flatten(),
510 ),
511 };
512 Box::pin(CountedEvents {
513 inner,
514 _live: EventsGuard(count),
515 })
516 }
517
518 pub async fn close(&self, grace: Duration) {
523 self.close_with_reason(grace, "quit").await;
524 }
525
526 async fn close_with_reason(&self, grace: Duration, reason: &str) {
529 if self.closed.swap(true, Ordering::SeqCst) {
530 return;
531 }
532 let _gate = self.gate.lock().await;
535 let grace = grace.min(CLOSE_GRACE_CAP);
536 let live = lock(&self.session).take();
537 if let Some(session) = live {
538 if !session.is_idle()
539 && tokio::time::timeout(grace, session.wait_for_idle())
540 .await
541 .is_err()
542 {
543 session.abort().await;
544 }
545 let runner = session.runner();
546 if runner.has_handlers("session_shutdown") {
547 runner
548 .emit(json!({"type": "session_shutdown", "reason": reason}))
549 .await;
550 }
551 if let Some(router) = session.inner.agents.router() {
552 router.detach(&self.id);
553 }
554 session.dispose().await;
555 session.hub().close();
556 } else {
557 if let Some(router) = self.parent.upgrade().and_then(|p| p.agents.router()) {
560 router.detach(&self.id);
561 }
562 }
563 if let Some(task) = lock(&self.forward).take() {
564 task.abort();
565 }
566 self.release_lease();
567 if let Some(parent) = self.parent.upgrade() {
568 parent.agents.remove_child(&self.id);
569 AgentSession::from_inner(parent).publish_agents_changed();
570 }
571 self.tree.release();
572 *lock(&self.idle_since) = None;
573 }
574
575 fn release_lease(&self) {
577 self.lease_epoch.fetch_add(1, Ordering::SeqCst);
578 drop(lock(&self.lease).take());
579 }
580
581 async fn on_taken_over(&self, epoch: u64, session_file: &Path, by: LeaseInfo) {
586 {
587 let held = lock(&self.lease);
588 let still_current = self.lease_epoch.load(Ordering::SeqCst) == epoch
589 && held
590 .as_ref()
591 .is_some_and(|lease| lease.session_file() == session_file);
592 if !still_current {
593 return;
594 }
595 }
596 match self.session_slot() {
597 Some(session) => session.hub().publish(OinoEvent::SessionTakenOver { by }),
598 None => {
600 if let Some(parent) = self.parent.upgrade() {
601 parent.hub.publish(OinoEvent::SessionTakenOver { by });
602 }
603 }
604 }
605 self.close_with_reason(Duration::ZERO, "takeover").await;
606 }
607}
608
609fn max_concurrent(session: &AgentSession) -> usize {
611 subagents_setting(session, "maxConcurrent")
612 .and_then(|n| usize::try_from(n).ok())
613 .unwrap_or(DEFAULT_MAX_CONCURRENT)
614 .clamp(1, HARD_MAX_CONCURRENT)
615}
616
617fn hibernate_after_sec(session: &AgentSession) -> u64 {
619 subagents_setting(session, "hibernateAfterSec").unwrap_or(DEFAULT_HIBERNATE_AFTER_SEC)
620}
621
622fn subagents_setting(session: &AgentSession, key: &str) -> Option<u64> {
624 session.with_settings(|s| {
625 s.settings()
626 .merged()
627 .get("extensionSettings")
628 .and_then(|e| e.get("subagents"))
629 .and_then(|e| e.get(key))
630 .and_then(JsonValue::as_u64)
631 })
632}
633
634#[derive(Clone, Copy, PartialEq, Eq)]
636enum ChildOpen {
637 Spawn,
640 Wake,
644}
645
646impl AgentSession {
647 fn resolve_child_model(&self, spec: &AgentSpec) -> Result<Model, HostError> {
648 match &spec.model {
649 Some(key) => key
650 .split_once('/')
651 .and_then(|(provider, id)| self.inner.models.find(provider, id))
652 .ok_or_else(|| HostError::Invalid(format!("Unknown model {key}"))),
653 None => self
654 .model()
655 .ok_or_else(|| HostError::Failed("No model selected".into())),
656 }
657 }
658
659 fn child_manager(&self, spec: &AgentSpec, cwd: &Path) -> Result<SessionManager, HostError> {
660 let parent_file = self.session_file();
661 let options = CreateOptions {
662 id: None,
663 parent_session: parent_file.clone(),
664 };
665 let dir = match &spec.persistence {
666 Persistence::Memory => None,
667 Persistence::File { session_dir } => session_dir.clone().or_else(|| {
668 parent_file
669 .as_deref()
670 .and_then(Path::parent)
671 .map(|d| d.join("children"))
672 }),
673 };
674 if let Seed::SessionFile(path) = &spec.seed {
675 if let Some(dir) = dir {
676 return SessionManager::open(path, &dir, Some(cwd)).map_err(failed);
677 }
678 let root = path.parent().unwrap_or(cwd);
679 let source = SessionManager::open(path, root, Some(cwd)).map_err(failed)?;
680 return Ok(SessionManager::in_memory(
681 cwd,
682 options,
683 source.entries().to_vec(),
684 ));
685 }
686 let mut manager = match &spec.seed {
687 Seed::BranchCopy => {
688 let branch = self.with_manager(|m| {
689 m.branch(None)
690 .into_iter()
691 .filter(|e| !matches!(e.message(), Some(AgentMessage::System(_))))
692 .cloned()
693 .collect()
694 });
695 SessionManager::in_memory(cwd, options, branch)
696 }
697 _ => SessionManager::in_memory(cwd, options, Vec::new()),
698 };
699 if let Seed::Messages(messages) = &spec.seed {
700 for message in messages {
701 manager.append_message(message.clone()).map_err(failed)?;
702 }
703 }
704 let Some(dir) = dir else {
705 return Ok(manager);
706 };
707 let path = dir.join(oino_session::manager::session_file_name(manager.header()));
709 let mut text = serde_json::to_string(manager.header()).map_err(failed)?;
710 text.push('\n');
711 for entry in manager.entries() {
712 text.push_str(&serde_json::to_string(entry).map_err(failed)?);
713 text.push('\n');
714 }
715 std::fs::create_dir_all(&dir).map_err(failed)?;
716 std::fs::write(&path, text).map_err(failed)?;
717 let manager = SessionManager::open(&path, &dir, Some(cwd)).map_err(failed)?;
718 Ok(manager)
719 }
720
721 fn child_extensions(&self, policy: &ExtensionPolicy) -> Arc<ExtensionSet> {
722 let all = &self.inner.agents.all_extensions;
723 match policy {
724 ExtensionPolicy::None => Arc::new(ExtensionSet::default()),
725 ExtensionPolicy::Inherit => Arc::clone(all),
726 ExtensionPolicy::Only(ids) => Arc::new(ExtensionSet::new(
727 all.extensions()
728 .iter()
729 .filter(|e| ids.iter().any(|id| id == e.instance.id()))
730 .cloned()
731 .collect(),
732 )),
733 }
734 }
735
736 #[allow(clippy::too_many_lines, reason = "one step per AgentSpec field")]
742 pub async fn spawn_agent(
743 &self,
744 extension: &str,
745 spec: AgentSpec,
746 ) -> Result<Arc<ChildAgent>, HostError> {
747 let agents = &self.inner.agents;
748 let depth = agents.depth.saturating_add(1);
749 if depth > agents.max_depth {
750 return Err(HostError::Failed(format!(
751 "Nested agent depth limit reached ({} levels)",
752 agents.max_depth
753 )));
754 }
755 let model = self.resolve_child_model(&spec)?;
756 let thinking = spec
757 .thinking
758 .unwrap_or_else(|| self.thinking_level())
759 .clamp(&model.available_thinking_levels());
760 let cwd = spec.cwd.clone().unwrap_or_else(|| self.cwd().to_path_buf());
761 let max = max_concurrent(self);
762 if !agents.tree.try_acquire(max) {
763 return Err(HostError::Failed(format!(
764 "Too many nested agents running (max {max})"
765 )));
766 }
767 let built = self
768 .build_child(
769 extension,
770 &spec,
771 model.clone(),
772 thinking,
773 &cwd,
774 depth,
775 ChildOpen::Spawn,
776 )
777 .await;
778 let (child, child_lease, child_file) = match built {
779 Ok(built) => built,
780 Err(error) => {
781 agents.tree.release();
782 return Err(error);
783 }
784 };
785 if let Some(router) = agents.router() {
786 router.attach(&child);
787 }
788 child.bind_extensions("startup", None).await;
789 let forward = self.spawn_isolated(
790 IsolatedTask::ChildEvents,
791 forward_child_events(
792 child.subscribe(),
793 Arc::downgrade(&self.inner),
794 child.inner.agents.link.clone(),
795 extension.to_owned(),
796 child.session_id(),
797 self.runner().call_context().clone(),
798 ),
799 );
800 let handle = Arc::new_cyclic(|watch_self| ChildAgent {
801 id: child.session_id(),
802 label: spec.label.clone(),
803 owner: extension.to_owned(),
804 session: Mutex::new(Some(child.clone())),
805 parent: Arc::downgrade(&self.inner),
806 tree: Arc::clone(&agents.tree),
807 closed: AtomicBool::new(false),
808 forward: Mutex::new(Some(forward)),
809 lease: Mutex::new(child_lease),
810 lease_epoch: AtomicU64::new(0),
811 gate: tokio::sync::Mutex::new(()),
812 spec,
813 model,
814 thinking: Mutex::new(thinking),
815 cwd,
816 depth,
817 role: child
818 .inner
819 .agents
820 .link
821 .as_ref()
822 .and_then(|link| link.role.clone()),
823 file: child_file.clone(),
824 last_usage: Mutex::new(None),
825 last_text: Mutex::new(None),
826 events_clients: Arc::new(AtomicUsize::new(0)),
827 idle_since: Mutex::new(None),
828 watch_self: std::sync::OnceLock::from(watch_self.clone()),
829 });
830 if let Some(file) = child_file {
831 let epoch = handle.lease_epoch.fetch_add(1, Ordering::SeqCst) + 1;
832 tokio::spawn(child_lease_watcher(Arc::downgrade(&handle), epoch, file));
833 tokio::spawn(hibernate_watcher(Arc::downgrade(&handle)));
835 }
836 lock(&agents.children).push(Arc::clone(&handle));
837 self.publish_agents_changed();
838 Ok(handle)
839 }
840
841 #[allow(
842 clippy::type_complexity,
843 reason = "(session, its lease, the leased file)"
844 )]
845 #[allow(
846 clippy::too_many_arguments,
847 reason = "one parameter per spawn input; mirrors createAgentSession"
848 )]
849 #[allow(clippy::too_many_lines, reason = "one step per SessionConfig field")]
850 async fn build_child(
851 &self,
852 extension: &str,
853 spec: &AgentSpec,
854 model: Model,
855 thinking: ThinkingLevel,
856 cwd: &Path,
857 depth: u8,
858 open: ChildOpen,
859 ) -> Result<(AgentSession, Option<SessionLease>, Option<PathBuf>), HostError> {
860 let agents = &self.inner.agents;
861 let mut manager = self.child_manager(spec, cwd)?;
862 let lease = match open {
867 ChildOpen::Spawn => match manager.session_file().filter(|_| manager.is_persisted()) {
868 Some(file) => {
869 let holder = process_lease_info(self.agent_dir(), self.mode());
870 Some(
871 acquire_lease(file, &holder, manager.session_id())
872 .await
873 .map_err(failed)?,
874 )
875 }
876 None => None,
877 },
878 ChildOpen::Wake => None,
879 };
880 let file = lease
881 .as_ref()
882 .map(|lease| lease.session_file().to_path_buf());
883 if matches!(spec.seed, Seed::SessionFile(_)) && manager.is_persisted() {
887 let report = manager.repair_interrupted_turn().map_err(failed)?;
888 if report.torn_tail || !report.closed_tool_calls.is_empty() {
889 tracing::info!(
890 session = manager.session_id(),
891 closed_tool_calls = ?report.closed_tool_calls,
892 torn_tail = report.torn_tail,
893 "child resume repair appended closers (D-REPAIR)"
894 );
895 }
896 }
897 manager
898 .append_model_change(&model.provider, &model.id)
899 .map_err(failed)?;
900 manager
901 .append_thinking_level_change(&thinking.to_string())
902 .map_err(failed)?;
903 let settings = self.with_settings(|s| s.snapshot());
904 let wanted: Vec<String> = spec.tools.builtin.clone().unwrap_or_else(|| {
905 settings.default_tools().unwrap_or_else(|| {
906 oino_settings::tools::DEFAULT_TOOL_NAMES
907 .iter()
908 .map(|s| (*s).to_owned())
909 .collect()
910 })
911 });
912 let context = Arc::new(SessionToolContext::default());
913 let mut builtin =
914 builtin_tool_entries(cwd, self.agent_dir(), &settings, Arc::clone(&context));
915 builtin.retain(|entry| wanted.iter().any(|w| w == entry.name()));
916 let mut allowed = wanted.clone();
917 allowed.extend(spec.tools.extension_tools.iter().cloned());
918 let parent_cx = self.runner().call_context().clone();
919 let has_ui = match spec.ui {
920 super::UiPolicy::Forward => parent_cx.has_ui,
921 super::UiPolicy::AutoDeny { .. } => true,
922 super::UiPolicy::None => false,
923 };
924 let child_role = spec
925 .child_role
926 .clone()
927 .or_else(|| (!extension.is_empty()).then(|| extension.to_owned()));
928 let max_depth = spec
929 .max_depth
930 .unwrap_or(DEFAULT_MAX_DEPTH)
931 .min(agents.max_depth)
932 .min(HARD_MAX_DEPTH);
933 let session = AgentSession::new(SessionConfig {
934 cwd: cwd.to_path_buf(),
935 agent_dir: self.agent_dir().to_path_buf(),
936 docs_root: self.inner.docs_root.clone(),
937 manager,
938 settings: Arc::clone(&self.inner.settings),
939 resources: Arc::clone(&self.inner.resources),
940 models: self.inner.models.clone(),
941 extensions: self.child_extensions(&spec.extensions),
942 stream: Arc::clone(&agents.base_stream),
943 mode: self.mode(),
944 has_ui,
945 builtin_tools: builtin,
946 custom_tools: Vec::new(),
947 initial_active_tools: wanted,
948 allowed_tools: Some(allowed),
949 excluded_tools: spec.tools.deny.clone(),
950 model: Some(model),
951 thinking_level: thinking,
952 scoped_models: Vec::new(),
953 flag_values: self.runner().flag_values(),
954 child: Some(ChildConfig {
955 link: ChildLink {
956 parent: Arc::downgrade(&self.inner),
957 label: spec.label.clone(),
958 role: child_role,
959 ui: spec.ui.clone(),
960 prompt: spec.system_prompt.clone(),
961 },
962 all_extensions: Arc::clone(&agents.all_extensions),
963 base_stream: Arc::clone(&agents.base_stream),
964 tree: Arc::clone(&agents.tree),
965 depth,
966 max_depth,
967 router: agents.router.get().cloned(),
968 }),
969 });
970 context.bind(&session);
971 Ok((session, lease, file))
972 }
973
974 #[must_use]
976 pub fn agents_list(&self) -> JsonValue {
977 let children = lock(&self.inner.agents.children).clone();
978 JsonValue::Array(
979 children
980 .iter()
981 .map(|c| {
982 json!({
983 "id": c.id(),
984 "label": c.label(),
985 "state": c.state(),
986 "owner": c.owner(),
987 "sessionFile": c.session_file().map(|p| p.display().to_string()),
988 })
989 })
990 .collect(),
991 )
992 }
993
994 #[must_use]
996 pub fn child_agents(&self) -> Vec<Arc<ChildAgent>> {
997 lock(&self.inner.agents.children).clone()
998 }
999
1000 pub(crate) async fn close_children(&self) {
1003 let children = std::mem::take(&mut *lock(&self.inner.agents.children));
1004 for child in children {
1005 child.close(Duration::ZERO).await;
1006 }
1007 }
1008
1009 #[must_use]
1011 pub fn is_child(&self) -> bool {
1012 self.inner.agents.link.is_some()
1013 }
1014}
1015
1016async fn child_lease_watcher(child: Weak<ChildAgent>, epoch: u64, session_file: PathBuf) {
1021 loop {
1022 tokio::time::sleep(Duration::from_millis(500)).await;
1023 let Some(child) = child.upgrade() else {
1024 return; };
1026 if child.lease_epoch.load(Ordering::SeqCst) != epoch {
1027 return; }
1029 if let Some(request) = oino_session::read_takeover_request(&session_file) {
1030 child
1031 .on_taken_over(epoch, &session_file, request.requester)
1032 .await;
1033 return; }
1035 }
1036}
1037
1038async fn hibernate_watcher(child: Weak<ChildAgent>) {
1042 loop {
1043 tokio::time::sleep(HIBERNATE_POLL).await;
1044 let Some(child) = child.upgrade() else {
1045 return; };
1047 if child.is_closed() {
1048 return;
1049 }
1050 let Some(session) = child.session_slot() else {
1051 return; };
1053 let after = Duration::from_secs(hibernate_after_sec(&session));
1054 if after.is_zero() || !ChildAgent::hibernatable(&session, &child.events_clients) {
1055 *lock(&child.idle_since) = None;
1056 continue;
1057 }
1058 let due = {
1059 let mut since = lock(&child.idle_since);
1060 let start = *since.get_or_insert_with(Instant::now);
1061 start.elapsed() >= after
1062 };
1063 if due {
1064 child.hibernate().await;
1065 return;
1066 }
1067 }
1068}
1069
1070async fn forward_child_events(
1073 mut events: crate::hub::EventStream,
1074 parent: Weak<Inner>,
1075 link: Option<ChildLink>,
1076 owner: String,
1077 agent_id: String,
1078 owner_cx: CallContext,
1079) {
1080 let label = link.map(|l| l.label).unwrap_or_default();
1081 let kind = format!("child:{label}");
1082 while let Some(event) = events.next().await {
1083 let Some(parent) = parent.upgrade().map(AgentSession::from_inner) else {
1084 return;
1085 };
1086 super::forward_to_root(&parent.inner, &agent_id, &event);
1087 if super::changes_state(&event) {
1088 parent.publish_agents_changed();
1089 }
1090 if let ClientEvent::Session(AgentSessionEvent::MessageEnd {
1091 message: AgentMessage::Assistant(message),
1092 }) = &event
1093 {
1094 let appended = parent.with_manager(|m| {
1095 m.append_usage(
1096 &kind,
1097 &message.provider,
1098 &message.model,
1099 message.usage,
1100 None,
1101 )
1102 });
1103 match appended {
1104 Ok(id) => parent.publish_entry(&id),
1105 Err(error) => tracing::warn!("recording child usage failed: {error}"),
1106 }
1107 }
1108 let owner_ext = parent
1109 .runner()
1110 .set()
1111 .get(&owner)
1112 .cloned()
1113 .or_else(|| parent.inner.agents.all_extensions.get(&owner).cloned());
1114 if let Some(ext) = owner_ext
1115 && ext.state().events.contains("oino_agent")
1116 {
1117 let payload = json!({"type": "oino_agent", "agentId": agent_id, "event": event});
1118 if let Err(error) = ext
1119 .instance
1120 .on_event("oino_agent", payload, owner_cx.clone())
1121 .await
1122 {
1123 tracing::debug!("oino_agent delivery failed: {}", error.message);
1124 }
1125 }
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use crate::testing::{FauxProvider, FauxResponse, TestSession};
1133 use serde_json::json;
1134
1135 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1139 async fn hibernation_drops_the_child_session_inner() {
1140 let dir = tempfile::tempdir().unwrap();
1141 let provider = FauxProvider::new(vec![FauxResponse::Text("child reply".into())]);
1142 let parent = TestSession {
1143 settings: json!({"extensionSettings": {"subagents": {"hibernateAfterSec": 1}}})
1144 .as_object()
1145 .unwrap()
1146 .clone(),
1147 cwd: dir.path().to_path_buf(),
1148 session_dir: Some(dir.path().join("sessions")),
1149 ..TestSession::default()
1150 }
1151 .build(Arc::clone(&provider))
1152 .await;
1153 let spec = serde_json::from_value(json!({
1154 "label": "sleeper",
1155 "persistence": {"file": {}},
1156 "tools": {"builtin": []},
1157 }))
1158 .unwrap();
1159 let child = parent.spawn_agent("test", spec).await.unwrap();
1160 assert_eq!(
1161 child.prompt("task one".into(), None).await.unwrap(),
1162 "started"
1163 );
1164 child.wait_idle().await;
1165 let inner: Weak<Inner> = Arc::downgrade(&child.session().unwrap().inner);
1166 let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1167 while !child.is_hibernated() {
1168 assert!(tokio::time::Instant::now() < deadline, "never hibernated");
1169 tokio::time::sleep(Duration::from_millis(50)).await;
1170 }
1171 assert_eq!(child.state(), "hibernated");
1172 assert!(child.session().is_none());
1173 assert!(
1175 inner.upgrade().is_none(),
1176 "child Inner survived hibernation"
1177 );
1178 }
1179}