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}