entelix_agents/subagent.rs
1//! `Subagent` — a brain↔hand pairing where both the tool surface and
2//! the skill surface are explicit, filtered subsets of the parent's
3//! authority. F7 mitigation: there is **no default constructor** —
4//! every `Subagent` must declare which tools (and skills) it can use.
5//!
6//! ## Layer-stack inheritance (managed-agent shape)
7//!
8//! Sub-agents take the parent's [`ToolRegistry`] directly and narrow
9//! it through [`ToolRegistry::restricted_to`] / [`ToolRegistry::filter`].
10//! The `Arc`-backed layer factory rides over verbatim — every
11//! cross-cutting concern attached at the parent (`PolicyLayer` for
12//! PII redaction and quota, `OtelLayer` for `gen_ai.tool.*` events,
13//! retry middleware) applies transparently to the sub-agent's
14//! dispatches. There is no path through this module that constructs
15//! a fresh `ToolRegistry::new()` — building one would silently drop
16//! the layer stack, and `scripts/check-managed-shape.sh` rejects the
17//! pattern statically.
18//!
19//! ## Sub-agent as tool — handoff
20//!
21//! Calling [`Subagent::into_tool`] consumes the sub-agent and
22//! returns a [`SubagentTool`] that satisfies the [`Tool`] trait. The
23//! parent's LLM dispatches the sub-agent like any other tool — the
24//! `task` field on the input is rendered as a fresh user message,
25//! the sub-agent's full ReAct loop runs, and the terminal assistant
26//! text rides back as the tool output. This is the Anthropic
27//! managed-agent "agent-as-tool" pattern in code form.
28//!
29//! Authority bounds carry through verbatim — the sub-agent inside
30//! [`SubagentTool`] sees only the tools and skills the operator
31//! whitelisted at construction. F7 holds at the dispatch boundary
32//! (the parent's tool registry exposes the wrapper, not the inner
33//! tools).
34
35use std::sync::Arc;
36
37use async_trait::async_trait;
38use serde_json::{Value, json};
39
40use entelix_core::ir::{ContentPart, Message, Role};
41use entelix_core::tools::{Tool, ToolEffect, ToolMetadata};
42use entelix_core::{AgentContext, Error, ExecutionContext, Result, SkillRegistry, ToolRegistry};
43use entelix_runnable::Runnable;
44
45use crate::agent::{AgentEventSink, Approver};
46use crate::react_agent::react_agent_builder;
47use crate::state::ReActState;
48
49/// A bounded brain↔hand pairing.
50///
51/// The agent loop uses `model` as the brain, dispatching tools
52/// through `tool_registry` — a narrowed view of the parent registry
53/// that inherits the parent's layer stack at zero copy cost (see
54/// module docs for the managed-agent rationale).
55///
56/// The constructor mandates an explicit filter (or whitelist). This
57/// is the F7 mitigation: there is no `Default`, no `with_all_tools`
58/// shortcut. Callers must say which tools and which skills they
59/// trust the sub-agent with.
60pub struct Subagent<M>
61where
62 M: Runnable<Vec<Message>, Message> + 'static,
63{
64 name: String,
65 description: String,
66 model: Arc<M>,
67 tool_registry: ToolRegistry,
68 skills: SkillRegistry,
69 sinks: Vec<Arc<dyn AgentEventSink<ReActState>>>,
70 approver: Option<Arc<dyn Approver>>,
71}
72
73/// Compact metadata snapshot of a [`Subagent`] for parent-side
74/// inspection — the LLM-facing identity (`name`, `description`)
75/// plus the tool surface bound at construction. Operators that
76/// list available sub-agents in a parent agent's system prompt
77/// reach for this struct rather than calling each accessor
78/// individually.
79///
80/// The `description` is the same one-line summary the
81/// [`Subagent::builder`] constructor received; longer dev-side
82/// documentation belongs in code comments, not in metadata.
83#[derive(Clone, Debug)]
84pub struct SubagentMetadata {
85 /// LLM-facing tool name. Same value [`Subagent::name`] returns.
86 pub name: String,
87 /// One-line description for parent-side tool listings.
88 pub description: String,
89 /// Tools the sub-agent can dispatch (count).
90 pub tool_count: usize,
91 /// Tool names the sub-agent can dispatch — order unspecified.
92 pub tool_names: Vec<String>,
93}
94
95impl<M> std::fmt::Debug for Subagent<M>
96where
97 M: Runnable<Vec<Message>, Message> + 'static,
98{
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 // `model` is intentionally omitted — `M` is not bounded on
101 // `Debug` and forwarding would force every model wrapper to
102 // implement it. Counts are sufficient for diagnostic
103 // purposes; structural fields are surfaced via accessors.
104 f.debug_struct("Subagent")
105 .field("name", &self.name)
106 .field("description", &self.description)
107 .field("tool_count", &self.tool_registry.len())
108 .field("skill_count", &self.skills.len())
109 .field("sinks", &self.sinks.len())
110 .field("has_approver", &self.approver.is_some())
111 .finish_non_exhaustive()
112 }
113}
114
115impl<M> Subagent<M>
116where
117 M: Runnable<Vec<Message>, Message> + 'static,
118{
119 /// Open a [`SubagentBuilder`] anchored at `parent_registry`. The
120 /// builder is the sole construction surface — operators set
121 /// the LLM-facing identity (`name`, `description`), choose the
122 /// tool selection (`restrict_to` / `filter`), and attach optional
123 /// `sink` / `approver` / `skills` before calling
124 /// [`SubagentBuilder::build`].
125 ///
126 /// `name` and `description` flow through `into_tool` to populate
127 /// [`SubagentTool`]'s metadata; declaring them at the builder
128 /// surface (not at `into_tool`) makes the sub-agent's identity
129 /// inspectable via [`Subagent::name`] / [`Subagent::description`]
130 /// before the conversion-to-tool boundary, which matters for
131 /// operators that list sub-agent metadata in a parent agent's
132 /// system prompt.
133 pub fn builder(
134 model: M,
135 parent_registry: &ToolRegistry,
136 name: impl Into<String>,
137 description: impl Into<String>,
138 ) -> SubagentBuilder<'_, M> {
139 SubagentBuilder::new(model, parent_registry, name.into(), description.into())
140 }
141
142 /// LLM-facing name surfaced to the parent's tool dispatch.
143 /// Equal to the [`SubagentTool`]'s metadata name after
144 /// [`Self::into_tool`].
145 pub fn name(&self) -> &str {
146 &self.name
147 }
148
149 /// One-line description surfaced to the parent's tool listing.
150 pub fn description(&self) -> &str {
151 &self.description
152 }
153
154 /// Compact metadata snapshot for parent-side inspection.
155 /// Convenient when the parent's system prompt enumerates
156 /// available sub-agents and their tool surfaces without
157 /// consuming the [`Subagent`] (which `into_tool` does).
158 #[must_use]
159 pub fn metadata(&self) -> SubagentMetadata {
160 SubagentMetadata {
161 name: self.name.clone(),
162 description: self.description.clone(),
163 tool_count: self.tool_registry.len(),
164 tool_names: self.tool_registry.names().map(str::to_owned).collect(),
165 }
166 }
167
168 /// Number of tools the sub-agent can dispatch.
169 #[must_use]
170 pub fn tool_count(&self) -> usize {
171 self.tool_registry.len()
172 }
173
174 /// Names of the tools the sub-agent can dispatch — useful for
175 /// audit log entries. Order is unspecified.
176 #[must_use]
177 pub fn tool_names(&self) -> Vec<&str> {
178 self.tool_registry.names().collect()
179 }
180
181 /// Borrow the narrowed tool registry the sub-agent dispatches
182 /// through. The view inherits the parent's layer stack —
183 /// `PolicyLayer`, `OtelLayer`, retry middleware all apply.
184 #[must_use]
185 pub const fn tool_registry(&self) -> &ToolRegistry {
186 &self.tool_registry
187 }
188
189 /// Borrow the filtered skill registry the sub-agent inherited.
190 /// Empty when [`SubagentBuilder::with_skills`] was never called.
191 #[must_use]
192 pub const fn skills(&self) -> &SkillRegistry {
193 &self.skills
194 }
195
196 /// Build a `ReAct` loop bound to this sub-agent's narrowed tool
197 /// registry (which inherits the parent's layer stack), model, and
198 /// — when [`SubagentBuilder::with_skills`] was called — the
199 /// three LLM-facing skill tools (`list_skills`, `activate_skill`,
200 /// `read_skill_resource`) backed by the inherited skill registry.
201 /// See and
202 /// §"Sub-agent layer-stack inheritance". Sub-agents with no
203 /// `with_skills` call build the registry without skill tools,
204 /// matching their declared authority.
205 pub fn into_react_agent(self) -> Result<crate::agent::Agent<ReActState>> {
206 let Self {
207 name: _,
208 description: _,
209 model,
210 tool_registry,
211 skills,
212 sinks,
213 approver,
214 } = self;
215 let model = ArcRunnable::new(model);
216 let registry_with_skills = if skills.is_empty() {
217 tool_registry
218 } else {
219 // `skills::install` returns the same registry with three
220 // additional `register(...)` calls — the layer stack is
221 // preserved (registry `register` is `Self -> Self` so
222 // `factory: Option<LayerFactory>` rides over).
223 entelix_tools::skills::install(tool_registry, skills)?
224 };
225 // When an approver is configured, wrap the (skills-augmented)
226 // registry with `ApprovalLayer` so every tool dispatch this
227 // sub-agent issues passes through `Approver::decide` first.
228 // Mirrors `ReActAgentBuilder::build` — operators
229 // get HITL from `Subagent::with_approver(approver)` alone,
230 // no extra registry wiring step.
231 let registry = match &approver {
232 Some(approver) => {
233 registry_with_skills.layer(crate::agent::ApprovalLayer::new(Arc::clone(approver)))
234 }
235 None => registry_with_skills,
236 };
237 // Sub-agents auto-advertise their narrowed tool catalogue —
238 // the parent's `RunOverrides::with_tool_specs` ensures the
239 // planner sees exactly the tools it can dispatch.
240 let tool_specs = registry.tool_specs();
241 let defaults = if tool_specs.is_empty() {
242 None
243 } else {
244 Some(entelix_core::RunOverrides::new().with_tool_specs(tool_specs))
245 };
246 let mut builder = react_agent_builder(model, registry, defaults)?;
247 for sink in sinks {
248 builder = builder.add_sink_arc(sink);
249 }
250 if let Some(approver) = approver {
251 builder = builder
252 .with_execution_mode(crate::agent::ExecutionMode::Supervised)
253 .with_approver_arc(approver);
254 }
255 builder.build()
256 }
257
258 /// Wrap this sub-agent as a [`Tool`] callable from the parent's
259 /// LLM. The resulting [`SubagentTool`] reports a metadata block
260 /// keyed by `name` / `description` and accepts a single
261 /// `task: string` input which is rendered as the first user
262 /// message of the sub-agent's ReAct loop.
263 ///
264 /// # Effect classification
265 ///
266 /// Sub-agents may dispatch arbitrary tools — without inspecting
267 /// every transitive tool, a conservative
268 /// [`ToolEffect::Mutating`] is reported. Operators that know the
269 /// sub-agent is read-only override via [`SubagentTool::with_effect`].
270 pub fn into_tool(self) -> Result<SubagentTool> {
271 let Self {
272 name, description, ..
273 } = &self;
274 let name = name.clone();
275 let description = description.clone();
276 let agent = self.into_react_agent()?;
277 Ok(SubagentTool::new(agent, name, description))
278 }
279}
280
281/// Sub-agent selection surface — `All`, strict `Restrict`, or
282/// graceful `Filter`. Constructed via the [`SubagentBuilder`] verbs
283/// `restrict_to` / `filter` / (default) `All`. The strict / graceful
284/// asymmetry is intentional: name-typos in `restrict_to` fail at
285/// construction (the operator declared a known set), whereas `filter`
286/// accepts an empty result (a predicate is allowed to match nothing).
287/// Boxed predicate over the parent registry's tool set; matches the
288/// shape `ToolRegistry::filter` consumes. Held in a type alias so
289/// the [`SubagentSelection::Filter`] variant stays readable.
290type ToolPredicate = Box<dyn Fn(&dyn Tool) -> bool + Send + Sync>;
291
292enum SubagentSelection {
293 All,
294 Restrict(Vec<String>),
295 Filter(ToolPredicate),
296}
297
298impl SubagentSelection {
299 fn apply(self, parent: &ToolRegistry) -> Result<ToolRegistry> {
300 match self {
301 Self::All => Ok(parent.clone()),
302 Self::Restrict(allowed) => {
303 let refs: Vec<&str> = allowed.iter().map(String::as_str).collect();
304 parent.restricted_to(&refs)
305 }
306 Self::Filter(predicate) => Ok(parent.filter(|tool| predicate(tool))),
307 }
308 }
309}
310
311/// Builder for [`Subagent`]. Construct via
312/// [`Subagent::builder(model, &parent_registry)`](Subagent::builder).
313///
314/// The builder is the sole construction path — the sub-agent's
315/// authority bounds (`restrict_to` / `filter`) and optional wiring
316/// (`add_sink` / `with_approver` / `with_skills`) compose fluently
317/// before [`Self::build`] finalises a [`Subagent`]. Authority bounds
318/// are mandatory in spirit (F7 mitigation): a builder that never
319/// calls `restrict_to` / `filter` produces a sub-agent inheriting
320/// every parent tool — operators making that choice must do so
321/// explicitly.
322pub struct SubagentBuilder<'a, M>
323where
324 M: Runnable<Vec<Message>, Message> + 'static,
325{
326 model: M,
327 parent_registry: &'a ToolRegistry,
328 name: String,
329 description: String,
330 selection: SubagentSelection,
331 skills_request: Option<(&'a SkillRegistry, Vec<String>)>,
332 sinks: Vec<Arc<dyn AgentEventSink<ReActState>>>,
333 approver: Option<Arc<dyn Approver>>,
334}
335
336impl<'a, M> SubagentBuilder<'a, M>
337where
338 M: Runnable<Vec<Message>, Message> + 'static,
339{
340 fn new(model: M, parent_registry: &'a ToolRegistry, name: String, description: String) -> Self {
341 Self {
342 model,
343 parent_registry,
344 name,
345 description,
346 selection: SubagentSelection::All,
347 skills_request: None,
348 sinks: Vec::new(),
349 approver: None,
350 }
351 }
352
353 /// Restrict the sub-agent to tools whose name appears in
354 /// `allowed`. Returns [`entelix_core::Error::Config`] at
355 /// [`Self::build`] time if any name is absent from the parent
356 /// registry — strict-name lookup catches typos at the build
357 /// boundary rather than at runtime tool dispatch.
358 #[must_use]
359 pub fn restrict_to(mut self, allowed: &[&str]) -> Self {
360 let owned: Vec<String> = allowed.iter().map(|s| (*s).to_owned()).collect();
361 self.selection = SubagentSelection::Restrict(owned);
362 self
363 }
364
365 /// Restrict the sub-agent to tools matching `predicate`. Unlike
366 /// [`Self::restrict_to`], an empty match set is accepted — the
367 /// predicate form trades typo-detection for arbitrary-shape
368 /// flexibility.
369 #[must_use]
370 pub fn filter<F>(mut self, predicate: F) -> Self
371 where
372 F: Fn(&dyn Tool) -> bool + Send + Sync + 'static,
373 {
374 self.selection = SubagentSelection::Filter(Box::new(predicate));
375 self
376 }
377
378 /// Append a sink that consumes the sub-agent's lifecycle
379 /// events. Multiple calls accumulate via the inner
380 /// [`crate::agent::FanOutSink`] — operators forward to the
381 /// parent's sink, an audit recorder, and a separate telemetry
382 /// pipeline by chaining `add_sink` calls. Without any call the
383 /// resulting agent uses [`crate::agent::DroppingSink`] (the
384 /// `AgentBuilder` default) and the parent loses visibility into
385 /// child runs.
386 #[must_use]
387 pub fn add_sink(mut self, sink: Arc<dyn AgentEventSink<ReActState>>) -> Self {
388 self.sinks.push(sink);
389 self
390 }
391
392 /// Use the parent's [`Approver`] for any tool dispatch the
393 /// sub-agent issues — supervised execution propagates from
394 /// parent to child unless the operator explicitly overrides.
395 #[must_use]
396 pub fn with_approver(mut self, approver: Arc<dyn Approver>) -> Self {
397 self.approver = Some(approver);
398 self
399 }
400
401 /// Attach an explicitly-named subset of the parent's skill
402 /// registry. Validation runs at [`Self::build`] time — if any
403 /// `allowed` name is absent from `parent_skills`, build returns
404 /// [`entelix_core::Error::Config`].
405 #[must_use]
406 pub fn with_skills(mut self, parent_skills: &'a SkillRegistry, allowed: &[&str]) -> Self {
407 let owned: Vec<String> = allowed.iter().map(|s| (*s).to_owned()).collect();
408 self.skills_request = Some((parent_skills, owned));
409 self
410 }
411
412 /// Finalise the sub-agent. Validates the selection and skills
413 /// requests against the parent registries, returning the first
414 /// configuration error encountered.
415 pub fn build(self) -> Result<Subagent<M>> {
416 let Self {
417 model,
418 parent_registry,
419 name,
420 description,
421 selection,
422 skills_request,
423 sinks,
424 approver,
425 } = self;
426 if name.is_empty() {
427 return Err(entelix_core::Error::config(
428 "SubagentBuilder: name cannot be empty",
429 ));
430 }
431 if description.is_empty() {
432 return Err(entelix_core::Error::config(
433 "SubagentBuilder: description cannot be empty",
434 ));
435 }
436 let tool_registry = selection.apply(parent_registry)?;
437 let skills = match skills_request {
438 None => SkillRegistry::new(),
439 Some((parent_skills, allowed)) => {
440 for name in &allowed {
441 entelix_core::identity::validate_config_identifier(
442 "SubagentBuilder::with_skills",
443 "skill name",
444 name,
445 )?;
446 }
447 let missing: Vec<&str> = allowed
448 .iter()
449 .map(String::as_str)
450 .filter(|name| !parent_skills.has(name))
451 .collect();
452 if !missing.is_empty() {
453 return Err(entelix_core::Error::config(format!(
454 "SubagentBuilder::with_skills: skill name(s) not in parent registry: {}",
455 missing.join(", ")
456 )));
457 }
458 let allowed_refs: Vec<&str> = allowed.iter().map(String::as_str).collect();
459 parent_skills.filter(&allowed_refs)
460 }
461 };
462 Ok(Subagent {
463 name,
464 description,
465 model: Arc::new(model),
466 tool_registry,
467 skills,
468 sinks,
469 approver,
470 })
471 }
472}
473
474/// Wrapper exposing a [`crate::agent::Agent`] as a [`Tool`]. Built
475/// via [`Subagent::into_tool`].
476///
477/// The dispatch contract: caller passes `{"task": "..."}`, the
478/// wrapper builds a fresh `ReActState` seeded with one
479/// `Role::User` message, runs the inner agent, and returns the
480/// final assistant text under `{"output": "..."}`. The full message
481/// trail is reachable via the agent's event sink — observability
482/// stays on the audit channel rather than the LLM-facing payload
483///.
484pub struct SubagentTool {
485 inner: crate::agent::Agent<ReActState>,
486 metadata: ToolMetadata,
487}
488
489impl SubagentTool {
490 fn new(inner: crate::agent::Agent<ReActState>, name: String, description: String) -> Self {
491 let metadata = ToolMetadata::function(
492 name,
493 description,
494 json!({
495 "type": "object",
496 "required": ["task"],
497 "properties": {
498 "task": {
499 "type": "string",
500 "description": "Concrete task for the sub-agent. \
501 Phrased as you would phrase a user \
502 message to a fresh assistant."
503 }
504 },
505 "additionalProperties": false
506 }),
507 )
508 .with_effect(ToolEffect::Mutating);
509 Self { inner, metadata }
510 }
511
512 /// Override the conservative [`ToolEffect::Mutating`] default
513 /// when the operator knows the sub-agent only reads.
514 #[must_use]
515 pub fn with_effect(mut self, effect: ToolEffect) -> Self {
516 self.metadata = self.metadata.with_effect(effect);
517 self
518 }
519}
520
521impl std::fmt::Debug for SubagentTool {
522 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523 // `inner` Agent is generic over its model and not bounded
524 // on Debug — surfacing it would force every model wrapper
525 // to implement Debug. Print the tool-side metadata only;
526 // the inner agent's identity surfaces through events.
527 f.debug_struct("SubagentTool")
528 .field("name", &self.metadata.name)
529 .field("effect", &self.metadata.effect)
530 .finish_non_exhaustive()
531 }
532}
533
534#[async_trait]
535impl Tool for SubagentTool {
536 fn metadata(&self) -> &ToolMetadata {
537 &self.metadata
538 }
539
540 async fn execute(&self, input: Value, ctx: &AgentContext<()>) -> Result<Value> {
541 let task = input.get("task").and_then(Value::as_str).ok_or_else(|| {
542 Error::invalid_request("SubagentTool: input must include a string 'task' field")
543 })?;
544 // Sub-agent dispatch crosses an audit boundary (invariant #18):
545 // a fresh `thread_id` scopes the child run's persistence and is
546 // emitted on the parent's `AuditSink` so a replay can identify
547 // which child thread the parent handed off to. UUID v7 keeps the
548 // identifier time-ordered, matching `CheckpointId`'s shape.
549 let sub_thread_id = uuid::Uuid::now_v7().to_string();
550 if let Some(handle) = ctx.audit_sink() {
551 handle
552 .as_sink()
553 .record_sub_agent_invoked(self.metadata.name.as_str(), &sub_thread_id);
554 }
555 let child_ctx = ctx.core().clone().with_thread_id(sub_thread_id);
556 let initial = ReActState::from_user(task);
557 let final_state = self.inner.invoke(initial, &child_ctx).await?;
558 // Surface only the terminal assistant text — see
559 // for the lean-output rationale. The full transcript is
560 // available to the parent's event sink via the agent's
561 // lifecycle events.
562 let output_text = final_state
563 .messages
564 .iter()
565 .rev()
566 .find(|m| matches!(m.role, Role::Assistant))
567 .and_then(|m| {
568 m.content.iter().find_map(|p| match p {
569 ContentPart::Text { text, .. } => Some(text.clone()),
570 _ => None,
571 })
572 })
573 .unwrap_or_default();
574 Ok(json!({ "output": output_text }))
575 }
576}
577
578/// Trivial Runnable wrapper around an `Arc<R>` so the sub-agent can
579/// hand its model to [`create_react_agent`] without consuming the Arc.
580struct ArcRunnable<R> {
581 inner: Arc<R>,
582}
583
584impl<R> ArcRunnable<R> {
585 const fn new(inner: Arc<R>) -> Self {
586 Self { inner }
587 }
588}
589
590#[async_trait::async_trait]
591impl<R> Runnable<Vec<Message>, Message> for ArcRunnable<R>
592where
593 R: Runnable<Vec<Message>, Message>,
594{
595 async fn invoke(&self, input: Vec<Message>, ctx: &ExecutionContext) -> Result<Message> {
596 self.inner.invoke(input, ctx).await
597 }
598}