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