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