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        #[serde(default)]
48        working_directory: PathBuf,
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        context: Option<String>,
51        #[serde(default, skip_serializing_if = "Vec::is_empty")]
52        files: Vec<SourceRange>,
53    },
54    ListAgents,
55    SendInput {
56        child_session_id: String,
57        message: String,
58    },
59    WaitAgents {
60        child_session_ids: Vec<String>,
61        #[serde(default, skip_serializing_if = "Option::is_none")]
62        timeout_seconds: Option<u64>,
63    },
64    InterruptAgent {
65        child_session_id: String,
66    },
67    CloseAgent {
68        child_session_id: String,
69    },
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct SubagentToolResult {
75    pub request_id: String,
76    pub completed_at_ms: i64,
77    pub is_error: bool,
78    pub message: String,
79}
80
81/// Durable ownership and launch intent for one child session.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct SubagentRecord {
85    pub child_session_id: String,
86    pub parent_session_id: String,
87    pub task_name: String,
88    pub profile_id: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub model: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub effort: Option<String>,
93    pub working_directory: PathBuf,
94    /// Complete first prompt after the controller captures requested ranges.
95    pub initial_prompt: String,
96    pub request_key: String,
97    pub created_at: String,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub delivered_turn: Option<u64>,
100}
101
102/// Lifecycle group used by the tool and both user interfaces.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum SubagentStatus {
106    Preparing,
107    Running,
108    InputRequired,
109    Completed,
110    Failed,
111    Interrupted,
112    Stopped,
113}
114
115impl SubagentRecord {
116    #[must_use]
117    pub fn is_child(&self, session_id: &str) -> bool {
118        self.child_session_id == session_id
119    }
120}