Skip to main content

oino_core/agents/
child.rs

1//! Spawning and driving one nested agent session.
2
3use 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
34/// How often the hibernation watcher re-examines an idle child (E-HIBERNATE).
35const HIBERNATE_POLL: Duration = Duration::from_millis(500);
36
37fn failed(error: impl std::fmt::Display) -> HostError {
38    HostError::Failed(error.to_string())
39}
40
41/// A nested agent session owned by an extension (`agents.agent`).
42///
43/// A file-persisted child that stays idle hibernates (E-HIBERNATE): the live
44/// [`AgentSession`] is disposed and this shell keeps `{id, label, owner, session_file,
45/// spec}` plus the file lease; the next handle operation rebuilds the session from the
46/// file (cold resume + D-REPAIR) with the same session id.
47pub struct ChildAgent {
48    id: String,
49    label: String,
50    owner: String,
51    /// The live child session (`None` while hibernated).
52    session: Mutex<Option<AgentSession>>,
53    parent: Weak<Inner>,
54    tree: Arc<Tree>,
55    closed: AtomicBool,
56    forward: Mutex<Option<JoinHandle<()>>>,
57    /// Single-writer lease of the child's session file (D-LOCK); children hold their own.
58    /// Hibernation keeps it: the shell owns the file until it closes.
59    lease: Mutex<Option<SessionLease>>,
60    /// Bumped whenever `lease` changes; a stale takeover watcher exits on a mismatch.
61    lease_epoch: AtomicU64,
62    /// Serializes hibernation and wake against handle operations; a tokio mutex because
63    /// it is held across awaits.
64    gate: tokio::sync::Mutex<()>,
65    /// The spawn spec, stored for the wake rebuild (E-HIBERNATE).
66    spec: AgentSpec,
67    /// Model resolved at spawn; reused on every wake so the child keeps its model.
68    model: Model,
69    /// Thinking level; updated by `set_thinking` so a wake restores the latest level.
70    thinking: Mutex<ThinkingLevel>,
71    /// Working directory resolved at spawn.
72    cwd: PathBuf,
73    /// Nesting depth (feed cache while hibernated).
74    depth: u8,
75    /// Child role visible to extensions (feed cache while hibernated).
76    role: Option<String>,
77    /// The child session file (`None` for memory-persisted children).
78    file: Option<PathBuf>,
79    /// Last-known usage JSON and assistant text, cached at hibernation.
80    last_usage: Mutex<Option<JsonValue>>,
81    last_text: Mutex<Option<String>>,
82    /// Live `events()` subscriptions; while any exists the child must not hibernate.
83    events_clients: Arc<AtomicUsize>,
84    /// When the hibernation preconditions first held (watcher bookkeeping).
85    idle_since: Mutex<Option<Instant>>,
86    /// Weak self reference for spawning per-child tasks (`Arc::new` + immediate set).
87    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
99/// Decrements the `events()` client count when the stream is dropped (E-HIBERNATE guard).
100struct EventsGuard(Arc<AtomicUsize>);
101
102impl Drop for EventsGuard {
103    fn drop(&mut self) {
104        self.0.fetch_sub(1, Ordering::SeqCst);
105    }
106}
107
108/// An `events()` stream that keeps the child awake while it lives.
109struct 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    /// Agent id (the child's session id).
127    #[must_use]
128    pub fn id(&self) -> &str {
129        &self.id
130    }
131
132    /// Label from the spec.
133    #[must_use]
134    pub fn label(&self) -> &str {
135        &self.label
136    }
137
138    /// Extension that spawned the agent.
139    #[must_use]
140    pub fn owner(&self) -> &str {
141        &self.owner
142    }
143
144    /// The live child session (`None` while hibernated — E-HIBERNATE; the transcript
145    /// lives in the session file and returns on wake). Cheap clone (`Arc` inside).
146    #[must_use]
147    pub fn session(&self) -> Option<AgentSession> {
148        lock(&self.session).clone()
149    }
150
151    /// Clones the live child session out of the shell, if awake.
152    fn session_slot(&self) -> Option<AgentSession> {
153        lock(&self.session).clone()
154    }
155
156    /// The child's transcript (empty while hibernated; the next waking handle
157    /// operation rebuilds it, or read the session file directly).
158    #[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    /// Child session file (`None` in memory; stable across hibernation).
166    #[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    /// Whether [`ChildAgent::close`] ran.
175    #[must_use]
176    pub fn is_closed(&self) -> bool {
177        self.closed.load(Ordering::SeqCst)
178    }
179
180    /// Whether the child hibernated (E-HIBERNATE): the live session was disposed and the
181    /// next handle operation rebuilds it from the session file.
182    #[must_use]
183    pub fn is_hibernated(&self) -> bool {
184        !self.is_closed() && self.session_slot().is_none()
185    }
186
187    /// `running`, `idle`, `hibernated` or `closed`.
188    #[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    /// Nesting depth of the child.
200    #[must_use]
201    pub(crate) fn depth(&self) -> u8 {
202        self.depth
203    }
204
205    /// Role visible to extensions (`AgentSpec.childRole`, else the owner).
206    #[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    /// The live session, waking the child if it hibernated (E-HIBERNATE). Callers that
220    /// hold `gate` get the no-lock variant.
221    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    /// [`Self::session_or_wake`] for callers already holding `gate`.
227    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    /// Rebuilds the hibernated session (E-HIBERNATE): exactly the `spawn_agent` build
235    /// with `Seed::SessionFile(child_file)` plus D-REPAIR, reusing the lease the shell
236    /// never dropped. The session id — hence the agent id — is stable.
237    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            // `close` raced the rebuild: do not install a session into a closed shell.
284            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    /// Hibernates the child (E-HIBERNATE): emits `session_shutdown{hibernate}` to its
314    /// extensions, makes the session log durable, disposes the live session (transcript
315    /// projection, subscriptions, forward task) and keeps the shell with
316    /// `state() == "hibernated"`. Called by [`hibernate_watcher`] after the idle window.
317    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; // already hibernated
324        };
325        if !Self::hibernatable(&session, &self.events_clients) {
326            *lock(&self.session) = Some(session);
327            return;
328        }
329        // Serve read-only handle operations from the shell while hibernated.
330        *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        // Wake resumes from this very file: make every appended line durable first
339        // (best-effort — the lines are already in the page cache, fsync only guards a
340        // machine crash while the child sleeps).
341        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    /// Whether `session` may hibernate now (E-HIBERNATE preconditions): file-persisted,
360    /// no run, compaction, retry, wait or `!` command, no open dialog and no live
361    /// `events()` subscriber.
362    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    /// Prompts the child; while it runs the text is queued (`steer`, else follow-up).
375    /// Wakes a hibernated child first (E-HIBERNATE). Returns the disposition
376    /// (`started`, `queued`, `handled`).
377    ///
378    /// # Errors
379    ///
380    /// Closed agent, wake failures (session file, repair) and prompt errors.
381    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    /// Steering message while running; starts a run when idle.
410    ///
411    /// # Errors
412    ///
413    /// As [`ChildAgent::prompt`].
414    pub async fn steer(&self, text: String) -> Result<(), HostError> {
415        self.prompt(text, Some("steer")).await.map(|_| ())
416    }
417
418    /// Follow-up message while running; starts a run when idle (resume).
419    ///
420    /// # Errors
421    ///
422    /// As [`ChildAgent::prompt`].
423    pub async fn follow_up(&self, text: String) -> Result<(), HostError> {
424        self.prompt(text, Some("followUp")).await.map(|_| ())
425    }
426
427    /// Aborts the current run (returns at once). A hibernated child is idle by
428    /// definition, so this is a no-op there.
429    pub fn abort(&self) {
430        if let Some(session) = self.session_slot() {
431            tokio::spawn(async move { session.abort().await });
432        }
433    }
434
435    /// Sets the thinking level; returns the level clamped to the child's model. On a
436    /// hibernated child the level is stored and applied at wake.
437    ///
438    /// # Errors
439    ///
440    /// Unknown level.
441    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            // Hibernated: clamp against the spawn model and store the level; the wake
454            // build records it (`thinkingLevelChange`) and arms the woken session with it.
455            let clamped = level.clamp(&self.model.available_thinking_levels());
456            *lock(&self.thinking) = clamped;
457            Ok(clamped.to_string())
458        }
459    }
460
461    /// Text of the child's last assistant message (last-known while hibernated).
462    #[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    /// Resolves once the child is idle (immediately when closed or hibernated).
471    pub async fn wait_idle(&self) {
472        if let Some(session) = self.session_slot() {
473            session.wait_for_idle().await;
474        }
475    }
476
477    /// The child's `SessionStats` JSON (the shell's last-known stats while hibernated;
478    /// child usage entries are mirrored into the parent at every `MessageEnd` anyway).
479    #[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    /// Child client events (`AgentSessionEvent` JSON and friends) from now until
488    /// close. A live subscription keeps the child awake (E-HIBERNATE); on a hibernated
489    /// child the first poll wakes it.
490    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    /// Closes the agent: waits up to `grace` (≤ 5 s) for idle, aborts, emits
519    /// `session_shutdown{reason:"quit"}` to the child's extensions, disposes the session
520    /// and ends its event streams. Idempotent; a hibernated shell closes without
521    /// rebuilding its session.
522    pub async fn close(&self, grace: Duration) {
523        self.close_with_reason(grace, "quit").await;
524    }
525
526    /// [`ChildAgent::close`] with a `session_shutdown` reason (D-TAKEOVER uses
527    /// `"takeover"`). Also releases the child's lease (D-LOCK).
528    async fn close_with_reason(&self, grace: Duration, reason: &str) {
529        if self.closed.swap(true, Ordering::SeqCst) {
530            return;
531        }
532        // Serialize against hibernate/wake so a rebuild can never install a session into
533        // a closed shell (and a close never disposes a session another path owns).
534        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            // Hibernated: shutdown was emitted at hibernation and the session is
558            // gone; only shell bookkeeping remains.
559            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    /// Releases the child's single-writer lease and retires its watcher (D-LOCK).
576    fn release_lease(&self) {
577        self.lease_epoch.fetch_add(1, Ordering::SeqCst);
578        drop(lock(&self.lease).take());
579    }
580
581    /// D-TAKEOVER holder side: the child's session file was taken over by another
582    /// runner; close the child here (releasing the lease) and notify its subscribers. The
583    /// `epoch` + `session_file` guard ignores a stale watcher (one tick behind a lease
584    /// change) closing the child that replaced its own session file.
585    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            // Hibernated: the child hub is closed; surface the takeover on the parent.
599            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
609/// `extensionSettings.subagents.maxConcurrent` (default 8, hard cap 16).
610fn 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
617/// `extensionSettings.subagents.hibernateAfterSec` (default 120, 0 = never; E-HIBERNATE).
618fn hibernate_after_sec(session: &AgentSession) -> u64 {
619    subagents_setting(session, "hibernateAfterSec").unwrap_or(DEFAULT_HIBERNATE_AFTER_SEC)
620}
621
622/// A u64 under `extensionSettings.subagents.<key>`.
623fn 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/// How [`AgentSession::build_child`] opens the child session.
635#[derive(Clone, Copy, PartialEq, Eq)]
636enum ChildOpen {
637    /// Fresh spawn (`agents.spawn`): acquire the file lease and record the model and
638    /// thinking selection in the new session.
639    Spawn,
640    /// Hibernation wake (E-HIBERNATE): the shell still holds the lease of the very same
641    /// file, so no lease is acquired (or returned); the session resumes the file in
642    /// place with D-REPAIR.
643    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        // `--session-dir` semantics: the file lives directly in the child sessions dir.
708        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    /// `agents.spawn` (runtime §12): creates a nested session owned by `extension`.
737    ///
738    /// # Errors
739    ///
740    /// Depth or concurrency limit, unknown model, session file failures.
741    #[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            // E-HIBERNATE: file-persisted children hibernate after the idle window.
834            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        // D-LOCK: children acquire the lease of their own file when `Persistence::File`
863        // (fresh files are uniquely named, so this only contends on a `SessionFile` seed) —
864        // before the child writes anything to it. A hibernation wake (E-HIBERNATE) keeps
865        // the shell's lease instead: the child never stopped owning its file.
866        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        // D-REPAIR: a resumed child file is repaired under the lease before use, so the
884        // woken child (or a child spawned from a `SessionFile` seed) always starts from a
885        // balanced transcript.
886        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    /// Nested agents of this session (`agents.list`): `[{id, label, state, sessionFile}]`.
975    #[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    /// Open nested agents of this session.
995    #[must_use]
996    pub fn child_agents(&self) -> Vec<Arc<ChildAgent>> {
997        lock(&self.inner.agents.children).clone()
998    }
999
1000    /// Closes every nested agent (session disposal); hibernated shells close without
1001    /// rebuilding.
1002    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    /// Whether this is a nested (child) session.
1010    #[must_use]
1011    pub fn is_child(&self) -> bool {
1012        self.inner.agents.link.is_some()
1013    }
1014}
1015
1016/// Holder-side takeover watcher of a child's lease (D-TAKEOVER): polls the child session
1017/// file's `.takeover` request while the lease identified by `epoch` is current; on a
1018/// request the child is closed here (releasing the lease) so the other runner reloads it.
1019/// Runs through hibernation — the shell keeps the lease while it sleeps.
1020async 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; // child gone
1025        };
1026        if child.lease_epoch.load(Ordering::SeqCst) != epoch {
1027            return; // lease replaced or released
1028        }
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; // close released the lease; the epoch moved on
1034        }
1035    }
1036}
1037
1038/// Hibernation watcher (E-HIBERNATE): polls a file-persisted child; once it stayed
1039/// hibernatable for `hibernateAfterSec` (watcher re-armed at every wake), the child
1040/// hibernates. Exits when the child closes or hibernates.
1041async fn hibernate_watcher(child: Weak<ChildAgent>) {
1042    loop {
1043        tokio::time::sleep(HIBERNATE_POLL).await;
1044        let Some(child) = child.upgrade() else {
1045            return; // child gone
1046        };
1047        if child.is_closed() {
1048            return;
1049        }
1050        let Some(session) = child.session_slot() else {
1051            return; // hibernated (this watcher's job is done) or closing
1052        };
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
1070/// Mirrors child usage into the parent (`usage{kind:"child:<label>"}`) and delivers
1071/// `oino_agent` events to the owner when it subscribed.
1072async 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    /// E-HIBERNATE acceptance core: an idle file-persisted child hibernates within the
1136    /// window and the live `AgentSession` (its `Inner`) is actually dropped — observed
1137    /// through a weak reference, the strongest drop evidence available.
1138    #[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        // The only strong reference was the shell's: the whole AgentSession deallocated.
1174        assert!(
1175            inner.upgrade().is_none(),
1176            "child Inner survived hibernation"
1177        );
1178    }
1179}