mobius_cli/frontend/
headless.rs1use mobius::protocol::{
2 EventMsg, MessageAuthor, MessageSubmission, ModelStepContentPhase, Op, ReviewDecision,
3 Submission,
4};
5use mobius::{Error, Result};
6use mobius_gateway::client::{GatewayEvents, GatewaySender};
7use mobius_gateway::wire::{ClientMessage, ServerMessage};
8use uuid::Uuid;
9
10pub async fn run(
11 sender: GatewaySender,
12 mut events: GatewayEvents,
13 session_id: String,
14 task: String,
15) -> Result<Option<String>> {
16 let submission_id = Uuid::new_v4().to_string();
17 sender
18 .send(ClientMessage::Submit {
19 session_id: session_id.clone(),
20 recipient_bot_ids: Vec::new(),
21 submission: Submission {
22 id: submission_id.clone(),
23 op: Op::Message {
24 message: MessageSubmission {
25 author: MessageAuthor::User,
26 text: task,
27 attachments: Vec::new(),
28 reply: None,
29 requested_delivery: None,
30 target_turn_id: None,
31 },
32 },
33 },
34 })
35 .await
36 .map_err(gateway_error)?;
37
38 let mut turn_id = None;
39 let mut first_error = None;
40 let mut approval_error = None;
41 let mut last_assistant_message = None;
42 loop {
43 let frame =
44 events.next().await.map_err(gateway_error)?.ok_or_else(|| {
45 Error::Stopped("gateway disconnected before turn completion".into())
46 })?;
47 let event = match frame.message {
48 ServerMessage::AgentEvent {
49 session_id: actual,
50 record,
51 ..
52 } if actual == session_id => record.event,
53 ServerMessage::Rejected {
54 request_id,
55 message,
56 ..
57 } if request_id == submission_id => return Err(Error::Stopped(message)),
58 ServerMessage::Error { message, .. } => return Err(Error::Stopped(message)),
59 _ => continue,
60 };
61 match event.msg {
62 EventMsg::TurnStarted(turn)
63 if event.submission_id.as_deref() == Some(&submission_id) =>
64 {
65 turn_id = Some(turn.turn_id);
66 }
67 EventMsg::ExecApprovalRequest(request)
68 if turn_id.as_deref() == Some(request.turn_id.as_str()) =>
69 {
70 approval_error = Some(Error::Config(
71 "headless run requested tool approval; configure a no-prompt gateway sandbox policy"
72 .into(),
73 ));
74 sender
75 .send(ClientMessage::ReviewApproval {
76 request_id: Uuid::new_v4().to_string(),
77 approval_request_id: request.id,
78 decision: ReviewDecision::Abort,
79 })
80 .await
81 .map_err(gateway_error)?;
82 }
83 EventMsg::Error(error)
84 if event.submission_id.as_deref() == Some(submission_id.as_str()) =>
85 {
86 first_error.get_or_insert(error.message);
87 }
88 EventMsg::AssistantMessage(message)
89 if turn_id.as_deref() == Some(message.turn_id.as_str()) =>
90 {
91 let final_answer = message
92 .content
93 .into_iter()
94 .filter(|item| item.phase == ModelStepContentPhase::FinalAnswer)
95 .map(|item| item.text)
96 .collect::<String>();
97 if !final_answer.is_empty() {
98 last_assistant_message = Some(final_answer);
99 }
100 }
101 EventMsg::TurnComplete(turn) if turn_id.as_deref() == Some(turn.turn_id.as_str()) => {
102 if let Some(error) = approval_error {
103 return Err(error);
104 }
105 if let Some(error) = first_error {
106 return Err(Error::Stopped(error));
107 }
108 return Ok(last_assistant_message);
109 }
110 EventMsg::TurnAborted(turn) if turn_id.as_deref() == Some(turn.turn_id.as_str()) => {
111 return Err(approval_error
112 .unwrap_or_else(|| Error::Stopped(first_error.unwrap_or(turn.reason))));
113 }
114 _ => {}
115 }
116 }
117}
118
119fn gateway_error(error: mobius_gateway::Error) -> Error {
120 Error::Stopped(error.to_string())
121}