Skip to main content

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}