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