Skip to main content

mj_controller/server/api/
subagent_backend.rs

1use super::*;
2
3/// Everything the API needs from the daemon: live session actors, the durable
4/// projection, and the target-side git operations.
5///
6/// The daemon's implementation lives in `server_runtime::api`; route tests
7/// supply a fake.
8pub trait SubagentBackend: Send + Sync {
9    /// Cheap token for this session's durable wait inputs. Backends without
10    /// a committed publication leave filtering to the detailed observation.
11    fn wait_revision(&self, _session_id: &str) -> AnyResult<Option<u64>> {
12        Ok(None)
13    }
14
15    fn transcript_history(
16        &self,
17        session_id: String,
18        before: Option<mj_core::storage::TranscriptCursor>,
19    ) -> BoxFuture<'_, AnyResult<mj_core::storage::TranscriptHistoryPage>> {
20        Box::pin(async move {
21            tokio::task::spawn_blocking(move || {
22                crate::database::load_transcript_history(&session_id, before.as_ref(), 128)?
23                    .context("session history is unavailable")
24            })
25            .await?
26        })
27    }
28
29    fn native_agent_history(
30        &self,
31        owner: String,
32        child: String,
33        before: Option<(u64, String)>,
34    ) -> BoxFuture<'_, AnyResult<mj_core::native_agent::NativeAgentHistoryPage>> {
35        Box::pin(async move {
36            tokio::task::spawn_blocking(move || {
37                crate::database::native_agent_history(&owner, &child, before)
38            })
39            .await?
40        })
41    }
42    fn events(
43        &self,
44        filter: crate::database::ApiEventFilter,
45        after_seq: Option<u64>,
46    ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
47        events::load_events(filter, after_seq)
48    }
49
50    fn profile_config(
51        &self,
52        profile: String,
53        model: Option<String>,
54        refresh: bool,
55    ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
56        Box::pin(crate::controller::profile_config::discover(
57            profile, model, refresh,
58        ))
59    }
60    /// The profiles a parent running on `parent_profile` may start a sub-agent
61    /// on, each with its discovered choices and remaining quota. It waits for
62    /// a discovery the daemon has not finished; a profile whose discovery
63    /// fails is reported as unavailable rather than failing the whole answer.
64    fn subagent_candidates(
65        &self,
66        _parent_profile: String,
67    ) -> BoxFuture<'_, AnyResult<SubagentCandidates>> {
68        Box::pin(async { anyhow::bail!("sub-agent profiles are unavailable") })
69    }
70    /// Every enabled profile usable for a user-created session, with its
71    /// discovered choices and remaining quota. This is independent of the
72    /// profile set allowed for sub-agent use.
73    fn session_profile_candidates(&self) -> BoxFuture<'_, AnyResult<SubagentCandidates>> {
74        Box::pin(async { anyhow::bail!("session profile selection is unavailable") })
75    }
76    fn start_subagent(
77        &self,
78        _request: crate::controller::RegisterSubagentRequest,
79    ) -> BoxFuture<'_, AnyResult<mj_core::subagent::SubagentRecord>> {
80        Box::pin(async { anyhow::bail!("sub-agent creation is unavailable") })
81    }
82    fn list_subagents(
83        &self,
84        parent_session_id: String,
85    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::subagent::SubagentRecord>>> {
86        Box::pin(async move {
87            tokio::task::spawn_blocking(move || crate::database::list_subagents(&parent_session_id))
88                .await?
89        })
90    }
91    /// Whether a sub-agent child has handed back its report for its parent's
92    /// newest task; see [`crate::controller::subagent_has_handed_back`].
93    fn subagent_handed_back(&self, child_session_id: String) -> BoxFuture<'_, AnyResult<bool>> {
94        Box::pin(async move {
95            tokio::task::spawn_blocking(move || {
96                crate::controller::subagent_has_handed_back(&child_session_id)
97            })
98            .await?
99        })
100    }
101    fn read_context_file(
102        &self,
103        session_id: String,
104        path: PathBuf,
105    ) -> BoxFuture<'_, std::result::Result<Vec<u8>, ExportError>> {
106        self.read_file(session_id, path)
107    }
108    /// The workspaces the store holds, in the order the terminal's tabs and the
109    /// viewer's list show them.
110    fn list_workspaces(
111        &self,
112    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::workspace::WorkspaceRecord>>> {
113        Box::pin(async { tokio::task::spawn_blocking(crate::database::list_workspaces).await? })
114    }
115    /// The workspace with this name, creating it when the store holds none.
116    ///
117    /// This is the daemon's own `CreateWorkspace` operation: create-or-get, so
118    /// two callers that both saw an empty list attach to the same normalized
119    /// name instead of one of them meeting a SQLite conflict. The daemon
120    /// overrides it to republish the list afterwards.
121    fn create_workspace(
122        &self,
123        name: String,
124    ) -> BoxFuture<'_, AnyResult<mj_core::workspace::WorkspaceRecord>> {
125        Box::pin(async move {
126            tokio::task::spawn_blocking(move || crate::database::create_or_get_workspace(&name))
127                .await?
128        })
129    }
130    fn set_config(
131        &self,
132        session_id: String,
133        key: String,
134        value: String,
135    ) -> BoxFuture<'_, AnyResult<()>> {
136        Box::pin(async move {
137            self.session_handle(session_id)
138                .await?
139                .ok_or_else(|| anyhow::anyhow!("session has no live actor"))?
140                .set_config(key, value)
141                .await
142        })
143    }
144    fn cancel_start(&self, _session_id: String) -> BoxFuture<'_, AnyResult<()>> {
145        Box::pin(async { Ok(()) })
146    }
147    /// The live actor for a session, or `None` when none holds it.
148    fn session_handle(&self, session_id: String)
149    -> BoxFuture<'_, AnyResult<Option<SessionHandle>>>;
150
151    /// Submit a prompt, returning its relay acceptance ordinal.
152    fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>>;
153
154    /// Durable turn state for a session with no live actor.
155    fn turn_state(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>>;
156
157    /// A sub-agent child's recorded report, and whether it has the handback
158    /// tool. `None` for a session that is not a Mjolnir sub-agent.
159    fn subagent_report(
160        &self,
161        _session_id: String,
162    ) -> BoxFuture<'_, AnyResult<Option<(bool, mj_core::subagent::SubagentReport)>>> {
163        Box::pin(async { Ok(None) })
164    }
165
166    /// Summarize the turn that covers these transcript positions.
167    fn turn_summary(
168        &self,
169        session_id: String,
170        turn: TurnSpan,
171    ) -> BoxFuture<'_, AnyResult<TurnSummary>>;
172
173    /// Apply model, effort, and the first prompt once a new session is ready.
174    fn start_followup(
175        &self,
176        session_id: String,
177        followup: StartFollowup,
178    ) -> BoxFuture<'_, AnyResult<()>>;
179
180    /// How far a created session's follow-up has got.
181    fn start_status(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<StartStatus>>>;
182
183    /// A page of transcript items after `after_seq`.
184    fn transcript(
185        &self,
186        session_id: String,
187        after_seq: u64,
188        limit: usize,
189        roles: Vec<mj_core::transcript::TranscriptRole>,
190        finished_only: bool,
191    ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>>;
192
193    fn usage(
194        &self,
195        session_id: String,
196        after_seq: u64,
197        limit: usize,
198    ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsagePage>>> {
199        Box::pin(async move {
200            tokio::task::spawn_blocking(move || {
201                crate::database::load_session_usage(&session_id, after_seq, limit)
202            })
203            .await?
204        })
205    }
206
207    fn usage_tree(
208        &self,
209        parent: String,
210    ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsageTree>>> {
211        Box::pin(async move {
212            tokio::task::spawn_blocking(move || crate::database::load_usage_tree(&parent)).await?
213        })
214    }
215
216    /// A unified diff of the session's work.
217    fn diff(
218        &self,
219        session_id: String,
220        options: DiffOptions,
221    ) -> BoxFuture<'_, Result<String, ExportError>>;
222
223    /// One file from the session's workspace.
224    fn read_file(
225        &self,
226        session_id: String,
227        path: PathBuf,
228    ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>>;
229
230    fn write_file(
231        &self,
232        _session_id: String,
233        _path: PathBuf,
234        _bytes: Vec<u8>,
235        _overwrite: bool,
236    ) -> BoxFuture<'_, Result<(), ExportError>> {
237        Box::pin(async { Err(ExportError::Refused("file injection is unavailable".into())) })
238    }
239
240    /// Push the session's branch to its repository's default remote.
241    fn push_branch(
242        &self,
243        session_id: String,
244        branch: String,
245    ) -> BoxFuture<'_, Result<PushedBranch, ExportError>>;
246
247    /// A git bundle of the session's committed work.
248    fn bundle(&self, session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>>;
249
250    /// Whether the index was synced recently enough that a query need not ask
251    /// for one.
252    fn wiki_sync_is_stale(&self) -> bool {
253        false
254    }
255
256    /// Ask for a background sync. It is never waited for: a query answers from
257    /// what the index holds now.
258    fn wiki_request_sync(&self) {}
259
260    fn wiki_search(
261        &self,
262        _query: String,
263        _limit: usize,
264    ) -> BoxFuture<'_, AnyResult<mj_client::daemon::WikiSearchPage>> {
265        Box::pin(async { anyhow::bail!("SessionWiki search is unavailable") })
266    }
267
268    /// `None` when the index holds no session with that id.
269    fn wiki_brief(
270        &self,
271        _wiki_id: String,
272        _max_chars: usize,
273    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
274        Box::pin(async { anyhow::bail!("SessionWiki briefings are unavailable") })
275    }
276
277    /// The passages of one indexed session that match a query. `None` when the
278    /// index holds no session with that id.
279    fn wiki_hits(
280        &self,
281        _wiki_id: String,
282        _query: String,
283        _context_messages: usize,
284        _per_message_chars: usize,
285    ) -> BoxFuture<'_, AnyResult<Option<mj_client::daemon::WikiHitTranscript>>> {
286        Box::pin(async { anyhow::bail!("SessionWiki transcript hits are unavailable") })
287    }
288
289    /// What one indexed session is, and what continuing it would mean.
290    /// `None` when the index holds no session with that id.
291    fn wiki_session(
292        &self,
293        _wiki_id: String,
294    ) -> BoxFuture<'_, AnyResult<Option<mj_client::daemon::WikiSessionInfo>>> {
295        Box::pin(async { anyhow::bail!("SessionWiki lookups are unavailable") })
296    }
297
298    /// Start a session from an archived transcript, answering with its id, or
299    /// `None` when the index holds no session with that id.
300    fn wiki_restore(
301        &self,
302        _request: mj_client::daemon::WikiRestoreRequest,
303    ) -> BoxFuture<'_, AnyResult<Option<String>>> {
304        Box::pin(async { anyhow::bail!("SessionWiki restore is unavailable") })
305    }
306}
307
308pub(super) fn backend(state: &ServerState) -> Result<&Arc<dyn SubagentBackend>, ApiFailure> {
309    state
310        .subagent
311        .as_ref()
312        .ok_or_else(|| ApiFailure::unavailable("this server has no subagent backend installed"))
313}
314
315// ---------------------------------------------------------------------------
316// Wait resolution
317// ---------------------------------------------------------------------------