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