Skip to main content

leviath_runtime/host/
subagents.rs

1//! Sub-agent operations, which are the only control ops an *agent* can issue.
2//!
3//! Spawning a child, checking on one, cancelling a subtree. Kept apart from the
4//! control loop because these arrive from inside the world (an agent's tool
5//! call) rather than from a client, and the tree walks they need - ancestry,
6//! cancellation - exist nowhere else. The channel they arrive on is handed out
7//! here too, so the sender and its only reader are in one file.
8
9use super::*;
10
11impl WorldHost {
12    /// Service one [`SubAgentOp`] from a tool lane, replying on its oneshot.
13    pub(super) fn handle_subagent(&mut self, op: SubAgentOp) {
14        match op {
15            SubAgentOp::Spawn {
16                args,
17                parent_run_id,
18                max_depth,
19                reply,
20            } => {
21                let _ = reply.send(self.spawn_child(*args, &parent_run_id, max_depth));
22            }
23            SubAgentOp::Check { run_id, reply } => {
24                let report = self.live_entity(&run_id).and_then(|agent| {
25                    self.world.agent_status(agent).map(|status| SubAgentReport {
26                        status,
27                        final_output: self
28                            .world
29                            .world()
30                            .get::<crate::persistence::FinalOutput>(agent.entity())
31                            .map(|o| o.0.clone()),
32                    })
33                });
34                let _ = reply.send(report);
35            }
36            SubAgentOp::Send {
37                run_id,
38                caller_run_id,
39                content,
40                target_region,
41                reply,
42            } => {
43                if !self.is_within_tree(&run_id, &caller_run_id) {
44                    let _ = reply.send(false);
45                    return;
46                }
47                // Page the target in if it was unloaded, so delivery finds it.
48                self.resolve_or_reload(&run_id);
49                let ok = self
50                    .world
51                    .send_message(AgentMessage {
52                        agent_id: run_id,
53                        content,
54                        target_region,
55                    })
56                    .is_ok();
57                let _ = reply.send(ok);
58            }
59            SubAgentOp::Kill {
60                run_id,
61                caller_run_id,
62                reply,
63            } => {
64                let within = self.is_within_tree(&run_id, &caller_run_id);
65                let _ = reply.send(within && self.cancel_tree(&run_id));
66            }
67        }
68    }
69
70    /// Spawn a child agent under `parent_run_id`, linking `ParentRef` /
71    /// `SubAgentChildren` and registering its run id. `Err` if the parent is not
72    /// live, the depth limit is reached, or the spawner rejects it.
73    pub(super) fn spawn_child(
74        &mut self,
75        mut args: SpawnArgs,
76        parent_run_id: &str,
77        max_depth: usize,
78    ) -> Result<String, String> {
79        // Record the parentage so the child's run metadata nests it in the tree.
80        args.parent_run_id = Some(parent_run_id.to_string());
81        let parent = self
82            .live_entity(parent_run_id)
83            .ok_or_else(|| format!("parent run '{parent_run_id}' is not live"))?
84            // Same world as the child about to be spawned into it, so the raw
85            // entity is what the ECS links want.
86            .entity();
87        let parent_depth = self
88            .world
89            .world()
90            .get::<ParentRef>(parent)
91            .map_or(0, |p| p.depth);
92        let child_depth = parent_depth + 1;
93        if child_depth > max_depth {
94            return Err(format!(
95                "sub-agent depth limit ({max_depth}) reached; not spawning deeper"
96            ));
97        }
98        let run_id = args.run_id.clone();
99        let child = match self.spawner.as_mut() {
100            Some(spawner) => spawner(&mut self.world, &args)?,
101            None => return Err("this daemon cannot spawn agents".to_string()),
102        };
103        let world = self.world.world_mut();
104        world.entity_mut(child).insert(ParentRef {
105            parent_entity: parent,
106            parent_agent_id: parent_run_id.to_string(),
107            depth: child_depth,
108        });
109        match world.get_mut::<SubAgentChildren>(parent) {
110            Some(mut kids) => kids.children.push(child),
111            None => {
112                world.entity_mut(parent).insert(SubAgentChildren {
113                    children: vec![child],
114                    max_child_depth: max_depth,
115                });
116            }
117        }
118        // Record the child's run-id on the parent's serializable state so the
119        // tree is persisted (and restart can rebuild `SubAgentChildren`). A
120        // spawning parent always carries `AgentState`.
121        world
122            .get_mut::<crate::components::AgentState>(parent)
123            .expect("a spawning parent always has AgentState")
124            .spawned_children_ids
125            .push(run_id.clone());
126        // Seed the child's context from the parent per any declared blueprint
127        // context transform (planner→coder region mapping, etc.).
128        crate::context_transform::apply_context_transforms(
129            world,
130            crate::world::AgentId::in_world(world, parent),
131            crate::world::AgentId::in_world(world, child),
132        );
133        // The spawner ran against this world, so the child is ours.
134        let child_agent = self.world.own_agent(child);
135        self.by_run_id.insert(run_id.clone(), child_agent);
136        Ok(run_id)
137    }
138
139    /// Cancel a run and every descendant, paging the root in from disk first if it
140    /// had been unloaded. Returns whether the run was found in the world.
141    ///
142    /// Cancelling only the root would leave its sub-agents and fan-out workers
143    /// running - they are independent agents the schedule keeps driving, so they
144    /// would carry on spending tokens with no parent to report to. Each cancelled
145    /// agent's open interactions are closed too, so nothing is left blocked on a
146    /// prompt for a run that is going away.
147    /// Whether `run_id` is `ancestor` itself or one of its descendants.
148    ///
149    /// `send_to_agent` and `kill_agent` took any run id at all. Nothing tied the
150    /// target to the caller, so an agent could cancel an unrelated run, inject
151    /// text into its context, or - worst - hand it data: a message is added to
152    /// the target as `Public` regardless of the sender's taint, so an agent
153    /// holding `Private` context whose own outbound tools were gated could pass
154    /// it to a sibling whose tools were not. That is a laundering channel
155    /// straight through the middle of taint tracking.
156    ///
157    /// A downward walk from the caller, the same shape [`cancel_tree`] uses:
158    /// parentage is recorded as `SubAgentChildren`, so "is it mine" is "is it in
159    /// my subtree".
160    ///
161    /// [`cancel_tree`]: Self::cancel_tree
162    pub(super) fn is_within_tree(&mut self, run_id: &str, ancestor: &str) -> bool {
163        if run_id == ancestor {
164            return true;
165        }
166        // Both ends as entities: the host already maps run ids to them, and
167        // comparing entities avoids re-reading an id component per node.
168        let (Some(target), Some(root)) = (
169            self.resolve_or_reload(run_id),
170            self.resolve_or_reload(ancestor),
171        ) else {
172            return false;
173        };
174        // `SubAgentChildren` links are raw entities within this world, so the
175        // walk stays in that space and only the endpoints are world-scoped.
176        let target = target.entity();
177        let mut stack = vec![root.entity()];
178        while let Some(e) = stack.pop() {
179            if e == target {
180                return true;
181            }
182            if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
183                stack.extend(kids.children.iter().copied());
184            }
185        }
186        false
187    }
188
189    pub(super) fn cancel_tree(&mut self, run_id: &str) -> bool {
190        let Some(root) = self.resolve_or_reload(run_id) else {
191            return false;
192        };
193        // Collect the subtree (parent before children), then cancel each.
194        let mut subtree = Vec::new();
195        let mut stack = vec![root.entity()];
196        while let Some(e) = stack.pop() {
197            subtree.push(e);
198            if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
199                stack.extend(kids.children.iter().copied());
200            }
201        }
202        let mut cancelled = false;
203        for e in subtree {
204            // Read the agent id before cancelling - the entity stays valid until
205            // it is reaped, but reading first keeps this independent of that.
206            let agent_id = self
207                .world
208                .world()
209                .get::<AgentState>(e)
210                .map(|s| s.agent_id.clone());
211            cancelled |= self.world.cancel(self.world.own_agent(e));
212            if let Some(agent_id) = agent_id {
213                self.interactions.cancel_for_agent(&agent_id);
214                // The hub is keyed by agent id but the emitted-interaction set is
215                // keyed by request id, so drop the ids that are no longer pending.
216                let still_open: HashSet<String> = self
217                    .interactions
218                    .pending()
219                    .into_iter()
220                    .map(|(_, req)| req.id)
221                    .collect();
222                self.emitted_interactions
223                    .retain(|id| still_open.contains(id));
224            }
225        }
226        cancelled
227    }
228
229    /// A sender for [`SubAgentOp`]s. The daemon hands a clone to each agent's tool
230    /// state so the sub-agent tools can reach the world through the host.
231    pub fn subagent_sender(&self) -> UnboundedSender<SubAgentOp> {
232        self.subagent_tx.clone()
233    }
234}