Skip to main content

mj_core/
subagent.rs

1//! Durable contracts for Mjolnir-managed child-agent sessions.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// Longest time a sub-agent completion wait may remain pending.
8pub const MAX_WAIT_SECONDS: u64 = 3_600;
9
10/// Inclusive, one-based source lines captured for a child's initial context.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct SourceRange {
14    pub file: PathBuf,
15    pub start: u64,
16    pub end: u64,
17}
18
19/// One MCP request created inside a parent worker and consumed by the
20/// controller. `request_id` is the idempotency identity across reconnects.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct SubagentToolRequest {
24    pub request_id: String,
25    pub created_at_ms: i64,
26    pub action: SubagentToolAction,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(
31    tag = "action",
32    content = "params",
33    rename_all = "snake_case",
34    deny_unknown_fields
35)]
36pub enum SubagentToolAction {
37    ListProfiles,
38    Spawn {
39        task_name: String,
40        instructions: String,
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        profile_id: Option<String>,
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        model: Option<String>,
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        effort: Option<String>,
47        /// Absolute, or relative to the parent session's working directory.
48        /// Empty means the parent's own working directory. The directory must
49        /// exist on the target; no other restriction applies.
50        #[serde(default)]
51        working_directory: PathBuf,
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        context: Option<String>,
54        #[serde(default, skip_serializing_if = "Vec::is_empty")]
55        files: Vec<SourceRange>,
56    },
57    ListAgents,
58    SendInput {
59        child_session_id: String,
60        message: String,
61    },
62    WaitAgents {
63        child_session_ids: Vec<String>,
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        timeout_seconds: Option<u64>,
66    },
67    InterruptAgent {
68        child_session_id: String,
69    },
70    CloseAgent {
71        child_session_id: String,
72    },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct SubagentToolResult {
78    pub request_id: String,
79    pub completed_at_ms: i64,
80    pub is_error: bool,
81    pub message: String,
82}
83
84/// Durable ownership and launch intent for one child session.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct SubagentRecord {
88    pub child_session_id: String,
89    pub parent_session_id: String,
90    pub task_name: String,
91    pub profile_id: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub model: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub effort: Option<String>,
96    /// Launch directory for the child on the parent's target: absolute, or
97    /// relative to the parent session's working directory. Empty means the
98    /// parent's own working directory. The directory must exist; no other
99    /// restriction applies.
100    pub working_directory: PathBuf,
101    /// Complete first prompt after the controller captures requested ranges.
102    pub initial_prompt: String,
103    pub request_key: String,
104    pub created_at: String,
105    /// The child turn whose completion notice the parent's transcript has
106    /// already recorded, so restarts do not repeat the notice. Stored under
107    /// the historical field name `delivered_turn`.
108    #[serde(
109        default,
110        skip_serializing_if = "Option::is_none",
111        rename = "delivered_turn"
112    )]
113    pub noticed_turn: Option<u64>,
114}
115
116/// Lifecycle group used by the tool and both user interfaces.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum SubagentStatus {
120    Preparing,
121    Running,
122    InputRequired,
123    Completed,
124    Failed,
125    Interrupted,
126    Stopped,
127}
128
129impl SubagentRecord {
130    #[must_use]
131    pub fn is_child(&self, session_id: &str) -> bool {
132        self.child_session_id == session_id
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn noticed_turn_keeps_the_stored_delivered_turn_field_name() {
142        let record: SubagentRecord = serde_json::from_str(
143            r#"{"child_session_id":"c","parent_session_id":"p","task_name":"t","profile_id":"pr","working_directory":".","initial_prompt":"i","request_key":"k","created_at":"2026-09-15","delivered_turn":3}"#,
144        )
145        .expect("stored relation payloads remain readable");
146        assert_eq!(record.noticed_turn, Some(3));
147        let encoded = serde_json::to_value(&record).expect("record encodes");
148        assert_eq!(encoded["delivered_turn"], 3);
149        assert!(
150            encoded.get("noticed_turn").is_none(),
151            "the wire field name must stay historical: {encoded}"
152        );
153    }
154}