Skip to main content

forge_primitives/
subagents.rs

1//! Subagent declaration shapes. Pure data - no callbacks.
2//!
3//! `SubagentDefinition` and its nested types reach no CLI path today:
4//! the `initialize` `control_request`'s `agents` field was the only
5//! one. `SubagentEffort` and `EffortPreset` share this module and are
6//! separate: they have their own path through `Options::effort`, which
7//! the argv builder emits as `--effort`.
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::options::PermissionMode;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct SubagentDefinition {
17    /// Short description shown to the main agent when it picks a subagent.
18    pub description: String,
19    /// System prompt the subagent runs with.
20    pub prompt: String,
21    /// Tool allowlist for this subagent. `None` = no additional restriction.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub tools: Option<Vec<String>>,
24    /// Tool denylist for this subagent. Wire key is `disallowedTools`.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub disallowed_tools: Option<Vec<String>>,
27    /// Model alias (`"sonnet"`, `"opus"`, `"haiku"`, `"inherit"`) or a
28    /// full model ID. `None` = inherit the main agent's model.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub model: Option<String>,
31    /// Concrete skills this subagent should load.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub skills: Option<Vec<String>>,
34    /// Which `CLAUDE.md` scope to surface.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub memory: Option<SubagentMemory>,
37    /// MCP servers available to this subagent. Each entry is either a named
38    /// reference to a top-level server or an inline `{name: config}`
39    /// definition. Wire key is `mcpServers`.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub mcp_servers: Option<Vec<SubagentMcpServerRef>>,
42    /// Seed turn injected at subagent start. Wire key is `initialPrompt`.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub initial_prompt: Option<String>,
45    /// Upper bound on turns this subagent may take. Wire key is `maxTurns`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub max_turns: Option<u64>,
48    /// Run the subagent without surfacing its intermediate turns in the
49    /// parent transcript.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub background: Option<bool>,
52    /// Reasoning-effort hint.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub effort: Option<SubagentEffort>,
55    /// Override permission-mode for this subagent only. Wire key is
56    /// `permissionMode`.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub permission_mode: Option<PermissionMode>,
59}
60
61/// `CLAUDE.md` scope surfaced to a subagent. Wire shape:
62/// `Literal["user", "project", "local"]`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum SubagentMemory {
66    /// User-scope (`<config_dir>/CLAUDE.md`).
67    User,
68    /// Project-scope (`<repo>/CLAUDE.md`).
69    Project,
70    /// Project-local (`<repo>/.claude/CLAUDE.md.local`).
71    Local,
72}
73
74/// One entry in [`SubagentDefinition::mcp_servers`]. Either the name of an MCP
75/// server configured at the top level, or an inline `{name: config}` object.
76/// Matches the CLI's `list[str | dict[str, Any]]` type.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum SubagentMcpServerRef {
80    /// Named reference to a top-level MCP server.
81    Named(String),
82    /// Inline declaration. On the wire, emitted as `{<name>: <config>}`.
83    #[serde(with = "inline_mcp_server")]
84    Inline {
85        /// Server name - becomes the single key of the emitted object.
86        name: String,
87        /// Server config - becomes the value.
88        config: Value,
89    },
90}
91
92mod inline_mcp_server {
93    use serde::de::{Error as _, MapAccess, Visitor};
94    use serde::ser::SerializeMap;
95    use serde_json::Value;
96    use std::fmt;
97
98    pub(super) fn serialize<S: serde::Serializer>(
99        name: &str,
100        config: &Value,
101        serializer: S,
102    ) -> Result<S::Ok, S::Error> {
103        let mut map = serializer.serialize_map(Some(1))?;
104        map.serialize_entry(name, config)?;
105        map.end()
106    }
107
108    pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>(
109        d: D,
110    ) -> Result<(String, Value), D::Error> {
111        struct V;
112        impl<'de> Visitor<'de> for V {
113            type Value = (String, Value);
114            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115                f.write_str("a single-key {name: config} object")
116            }
117            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
118                let first: Option<(String, Value)> = map.next_entry()?;
119                let (name, config) =
120                    first.ok_or_else(|| M::Error::custom("empty inline MCP server object"))?;
121                if map.next_entry::<String, Value>()?.is_some() {
122                    return Err(M::Error::custom(
123                        "inline MCP server object must have exactly one key",
124                    ));
125                }
126                Ok((name, config))
127            }
128        }
129        d.deserialize_map(V)
130    }
131}
132
133/// Reasoning-effort hint on a subagent. Wire shape:
134/// `Literal["low","medium","high","max"] | int`. Named
135/// `SubagentEffort` to disambiguate from the runtime model effort
136/// in `forge_primitives::runtime::EffortLevel`.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum SubagentEffort {
140    /// One of the named presets.
141    Preset(EffortPreset),
142    /// Numeric override (CLI-defined semantics).
143    Numeric(i64),
144}
145
146impl SubagentEffort {
147    /// String form suitable for passing via `--effort <value>` or
148    /// any other CLI surface that expects a literal-or-int.
149    pub fn as_cli_arg(&self) -> String {
150        match self {
151            Self::Preset(p) => match p {
152                EffortPreset::Low => "low".into(),
153                EffortPreset::Medium => "medium".into(),
154                EffortPreset::High => "high".into(),
155                EffortPreset::Max => "max".into(),
156            },
157            Self::Numeric(n) => n.to_string(),
158        }
159    }
160}
161
162/// Named reasoning-effort presets. Wire shape:
163/// `Literal["low","medium","high","max"]`.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "lowercase")]
166pub enum EffortPreset {
167    /// Lowest effort - fast, shallow reasoning.
168    Low,
169    /// Balanced default.
170    Medium,
171    /// Deeper reasoning.
172    High,
173    /// Maximum reasoning (may be slow).
174    Max,
175}