Skip to main content

systemprompt_cli/commands/admin/agents/
message_streaming.rs

1//! Streaming message handling for `admin agents message`.
2//!
3//! The agent answers `SendStreamingMessage` with server-sent events, each a
4//! JSON-RPC response wrapping one A2A `StreamResponse`. The stream closing is
5//! the terminal signal; the consumer never reconnects.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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}