Skip to main content

agentd/agentloop/
action.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Self-tool dispatch.
3//!
4//! The agentic loop's tools come from connected MCP servers *plus* agentd's own
5//! self-tools (`subagent.spawn`, …). A [`SelfHandler`] supplies those tool
6//! definitions and handles their calls in-process — distinct from the MCP
7//! dispatch path. This is the seam through which the model **self-orchestrates**:
8//! it calls `subagent.spawn` to split its instruction into delegated child
9//! agents. The model only *asks*; the supervisor is what enforces the depth and
10//! concurrency caps and narrows the child's scope, so a compromised model
11//! cannot widen its own budget through this seam.
12
13use crate::wire::intel::ToolDef;
14use serde_json::Value;
15
16/// The classes of tool the agentic loop offers the model. This boundary is what
17/// keeps two invariants true: a task tool reaches the model ONLY by being
18/// exported from a registered MCP server or registered in code by the embedder,
19/// and nothing in the catalogue shells out to a local command. EVERY tool the
20/// loop advertises is exactly one of these classes; there is no third "general
21/// capability library" that could smuggle in an unaudited capability.
22///   * [`Mcp`](ToolClass::Mcp) — a tool discovered from a connected MCP server
23///     (`tools/list`). Dispatched by routing the call BACK to its owning server
24///     ([`dispatch_tool`](crate::agentloop::runner)); agentd never runs it locally.
25///   * [`SelfControl`](ToolClass::SelfControl) — agentd's OWN orchestration
26///     primitives (see [`SELF_CONTROL_TOOLS`]): delegation (`subagent.*`,
27///     `a2a.delegate`), reactivity (root-only `schedule`/`subscribe`/`unsubscribe`),
28///     and resource attention (`resource.read`). These are handled in-process by a
29///     [`SelfHandler`] / the runner — NONE shells out. This is the named
30///     "self/control" class: the agent's own control surface, structurally distinct
31///     from the MCP task-tool catalogue (a different code path assembles each).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ToolClass {
34    /// A tool from a connected MCP server; dispatched back to that server.
35    Mcp,
36    /// One of agentd's own self/control orchestration primitives; handled in-process.
37    SelfControl,
38    /// A CODE-REGISTERED tool: native Rust the embedder registered via
39    /// [`crate::tools::register`] — first-party by definition, dispatched
40    /// in-process, and it WINS a name collision with a remote MCP tool, so a
41    /// server cannot steal a registered tool's calls by claiming its name.
42    Code,
43}
44
45/// The authoritative membership of the [`ToolClass::SelfControl`] class: every
46/// self/control primitive name agentd may offer the model. The
47/// [`SelfHandler`] advertises a depth-/feature-conditioned SUBSET of this set
48/// (`a2a.delegate` only with peers; `schedule`/`subscribe`/`unsubscribe` only at
49/// the root; the `subagent.*` delegation tools only within the depth budget), and
50/// the runner adds `resource.read` when any resource is readable. A test asserts
51/// that everything a handler can advertise appears in this list, so a new
52/// self-tool cannot silently escape the class boundary. By construction the set
53/// contains no local-execution primitive.
54pub const SELF_CONTROL_TOOLS: &[&str] = &[
55    "subagent.spawn",
56    "subagent.status",
57    "subagent.await",
58    "schedule",
59    "subscribe",
60    "unsubscribe",
61    "await_resource",
62    "workflow.define",
63    "workflow.patch",
64    "workflow.run",
65    "a2a.delegate",
66    "resource.read",
67];
68
69/// Provides agentd's in-process self-tools to the loop. The loop tries the
70/// self-handler first; a `None` result means "not a self-tool — fall through to
71/// MCP".
72pub trait SelfHandler {
73    /// The self-tool definitions to advertise to the model (added to the MCP
74    /// catalogue).
75    fn tools(&self) -> Vec<ToolDef>;
76
77    /// Handle a tool call. Returns `Some((observation, is_error))` if `name` is
78    /// one of this handler's self-tools; `None` to fall through to MCP.
79    fn handle(&mut self, name: &str, args: &Value) -> Option<(String, bool)>;
80
81    /// Read an `agentd://` self-resource (e.g. `agentd://subagent/<handle>` — an
82    /// async child's completion). A `resource.read` for an `agentd://` URI routes
83    /// here instead of to MCP. Returns `Some((content, is_error))` if this handler
84    /// serves the URI; `None` (the default) means it does not, and the read falls
85    /// through to MCP.
86    fn read_resource(&mut self, _uri: &str) -> Option<(String, bool)> {
87        None
88    }
89
90    /// Whether this handler exposes any `agentd://` self-resources — so the loop
91    /// offers the `resource.read` tool even when no MCP resources exist. Default
92    /// `false`.
93    fn serves_self_resources(&self) -> bool {
94        false
95    }
96
97    /// Drain any future wake-ups the agent scheduled for itself this run.
98    /// Default: none. The loop attaches these to the run's
99    /// [`Outcome`](crate::agentloop::stop::Outcome) so a daemon supervisor can
100    /// arm them.
101    fn take_scheduled(&mut self) -> Vec<crate::agentloop::stop::ScheduleRequest> {
102        Vec::new()
103    }
104
105    /// Drain any resource (un)subscriptions the agent requested for itself this
106    /// run. Default: none. Attached to the run's `Outcome`.
107    fn take_subscriptions(&mut self) -> Vec<crate::agentloop::stop::SubscriptionRequest> {
108        Vec::new()
109    }
110}