systemprompt_cli/commands/admin/agents/
message_streaming.rs1use anyhow::{Context, Result, anyhow};
11use futures_util::{Stream, StreamExt};
12use reqwest::Client;
13use sse_stream::SseStream;
14use systemprompt_agent::models::a2a::jsonrpc::{JsonRpcResponse, Request};
15use systemprompt_agent::models::a2a::protocol::SendMessageParams;
16use systemprompt_logging::CliService;
17use systemprompt_models::a2a::{
18 Message, PROTOCOL_VERSION, StreamResponse, Task, TaskArtifactUpdateEvent, TaskState,
19 VERSION_HEADER,
20};
21
22use super::message::extract_text_from_parts;
23use super::types::MessageOutput;
24
25#[derive(Debug, Default)]
26pub struct StreamOutcome {
27 pub task: Option<Task>,
28 pub text: String,
29}
30
31pub(super) async fn execute_streaming(
32 agent: &str,
33 agent_url: &str,
34 auth_token: &str,
35 request: &Request<SendMessageParams>,
36 message_text: &str,
37) -> Result<MessageOutput> {
38 let response = Client::new()
39 .post(agent_url)
40 .header("Content-Type", "application/json")
41 .header("Accept", "text/event-stream")
42 .header("Authorization", format!("Bearer {auth_token}"))
43 .header(VERSION_HEADER, PROTOCOL_VERSION)
44 .json(request)
45 .send()
46 .await
47 .with_context(|| format!("Failed to open stream to agent at {agent_url}"))?;
48
49 if !response.status().is_success() {
50 let status = response.status();
51 let body = response.text().await.with_context(|| {
52 format!("Agent stream request failed with status {status}; its body was unreadable")
53 })?;
54 anyhow::bail!("Agent stream request failed with status {status}: {body}");
55 }
56
57 let outcome = collect_stream(response.bytes_stream(), &mut write_stdout_chunk).await?;
58 CliService::output("");
59
60 let response = if outcome.text.is_empty() {
61 outcome
62 .task
63 .as_ref()
64 .and_then(|task| task.status.message.as_ref())
65 .map(|msg| extract_text_from_parts(&msg.parts))
66 } else {
67 Some(outcome.text)
68 };
69
70 Ok(MessageOutput {
71 agent: agent.to_owned(),
72 task: outcome.task,
73 message_sent: message_text.to_owned(),
74 response,
75 })
76}
77
78pub async fn collect_stream<S, E>(
79 bytes: S,
80 on_text: &mut (dyn FnMut(&str) + Send),
81) -> Result<StreamOutcome>
82where
83 S: Stream<Item = Result<bytes::Bytes, E>> + Send,
84 E: std::error::Error + Send + Sync + 'static,
85{
86 let mut events = std::pin::pin!(SseStream::from_bytes_stream(bytes));
87 let mut outcome = StreamOutcome::default();
88
89 while let Some(event) = events.next().await {
90 let sse = event.map_err(|e| anyhow!("SSE stream error: {e}"))?;
91 let Some(data) = sse.data else {
92 continue;
93 };
94 apply_frame(&data, &mut outcome, on_text)?;
95 }
96
97 match outcome.task.as_ref() {
98 Some(task) if !stream_may_end_in(task.status.state) => anyhow::bail!(
99 "Stream closed while task {} was still {}",
100 task.id,
101 task.status.state.as_str()
102 ),
103 None if outcome.text.is_empty() => {
104 anyhow::bail!("Stream closed without a task or message")
105 },
106 _ => Ok(outcome),
107 }
108}
109
110const fn stream_may_end_in(state: TaskState) -> bool {
111 state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
112}
113
114fn apply_frame(
115 data: &str,
116 outcome: &mut StreamOutcome,
117 on_text: &mut (dyn FnMut(&str) + Send),
118) -> Result<()> {
119 let response: JsonRpcResponse<StreamResponse> =
120 serde_json::from_str(data).context("Agent sent an undecodable stream frame")?;
121
122 if let Some(error) = response.error {
123 let details = error
124 .data
125 .map_or_else(String::new, |d| format!("\n\nDetails: {d}"));
126 anyhow::bail!(
127 "Agent returned error ({}): {}{}",
128 error.code,
129 error.message,
130 details
131 );
132 }
133
134 match response.result {
135 Some(StreamResponse::Task(task)) => outcome.task = Some(task),
136 Some(StreamResponse::Message(message)) => emit_text(&message, outcome, on_text),
137 Some(StreamResponse::StatusUpdate(update)) => {
138 if let Some(message) = &update.status.message {
139 emit_text(message, outcome, on_text);
140 }
141 match outcome.task.as_mut() {
142 Some(task) => task.status = update.status,
143 None => {
144 outcome.task = Some(Task {
145 id: update.task_id,
146 context_id: update.context_id,
147 status: update.status,
148 ..Task::default()
149 });
150 },
151 }
152 },
153 Some(StreamResponse::ArtifactUpdate(update)) => apply_artifact(update, outcome),
154 None => {},
155 }
156 Ok(())
157}
158
159fn emit_text(
160 message: &Message,
161 outcome: &mut StreamOutcome,
162 on_text: &mut (dyn FnMut(&str) + Send),
163) {
164 let text = extract_text_from_parts(&message.parts);
165 if !text.is_empty() {
166 on_text(&text);
167 outcome.text.push_str(&text);
168 }
169}
170
171fn apply_artifact(update: TaskArtifactUpdateEvent, outcome: &mut StreamOutcome) {
172 let task = outcome.task.get_or_insert_with(|| Task {
173 id: update.task_id.clone(),
174 context_id: update.context_id.clone(),
175 ..Task::default()
176 });
177 let artifacts = task.artifacts.get_or_insert_with(Vec::new);
178 let existing = artifacts
179 .iter_mut()
180 .find(|artifact| artifact.id == update.artifact.id);
181 match existing {
182 Some(artifact) if update.append => artifact.parts.extend(update.artifact.parts),
183 Some(artifact) => *artifact = update.artifact,
184 None => artifacts.push(update.artifact),
185 }
186}
187
188fn write_stdout_chunk(text: &str) {
189 if let Err(e) = std::io::Write::write_all(&mut std::io::stdout(), text.as_bytes()) {
190 tracing::warn!(error = %e, "stdout write failed");
191 }
192 if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) {
193 tracing::warn!(error = %e, "stdout flush failed");
194 }
195}