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}