Skip to main content

open_agent/client/
query.rs

1/// A pinned, boxed stream of events from the model.
2///
3/// This type alias represents an asynchronous stream that yields [`StreamEvent`] items.
4/// Each item is wrapped in a `Result` to handle potential errors during streaming.
5///
6/// The stream is:
7/// - **Pinned** (`Pin<Box<...>>`): Required for safe async operations and self-referential types
8/// - **Boxed**: Allows dynamic dispatch and hides the concrete stream implementation
9/// - **Send**: Can be safely transferred between threads
10///
11/// # Events
12///
13/// - [`StreamEvent::Block`]: one [`ContentBlock`] — a fragment of assistant text as it
14///   arrives, or a fully assembled tool call
15/// - [`StreamEvent::Reasoning`]: chain-of-thought text, only when
16///   [`AgentOptions::include_reasoning`] is enabled
17/// - [`StreamEvent::Finish`]: exactly once, as the final item, carrying the
18///   [`FinishReason`]
19///
20/// # Migrating from `ContentStream` (0.7.x and earlier)
21///
22/// The stream used to yield bare `ContentBlock`s. Wrap the old match in
23/// [`StreamEvent::into_block`] to get the previous behaviour, then handle
24/// [`StreamEvent::Finish`] where the distinction between a clean stop and a truncated
25/// response matters.
26///
27/// # Error Handling
28///
29/// Errors in the stream indicate issues like:
30/// - Network failures or timeouts
31/// - Malformed SSE events
32/// - JSON parsing errors
33/// - API errors from the model provider
34///
35/// When an error occurs, the stream typically terminates. It's the caller's responsibility
36/// to handle errors appropriately.
37///
38/// # Examples
39///
40/// ```rust,no_run
41/// use open_agent::{query, AgentOptions, ContentBlock, FinishReason, StreamEvent};
42/// use futures::StreamExt;
43///
44/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
45/// let options = AgentOptions::builder()
46///     .model("gpt-4")
47///     .api_key("sk-...")
48///     .build()?;
49///
50/// let mut stream = query("Hello!", &options).await?;
51///
52/// while let Some(result) = stream.next().await {
53///     match result? {
54///         StreamEvent::Block(ContentBlock::Text(text)) => print!("{}", text.text),
55///         StreamEvent::Finish(reason) => println!("\nstopped: {reason}"),
56///         _ => {}
57///     }
58/// }
59/// # Ok(())
60/// # }
61/// ```
62pub type EventStream = Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>;
63
64/// Simple query function for single-turn interactions without conversation history.
65///
66/// This is a stateless convenience function for simple queries that don't require
67/// multi-turn conversations. It creates a temporary HTTP client, sends a single
68/// prompt, and returns a stream of events.
69///
70/// For multi-turn conversations or more control over the interaction, use [`Client`] instead.
71///
72/// # Parameters
73///
74/// - `prompt`: The user's message to send to the model
75/// - `options`: Configuration including model, API key, tools, etc.
76///
77/// # Returns
78///
79/// Returns an [`EventStream`] that yields events as they arrive from the model. The stream
80/// must be polled to completion to receive all content and the terminating
81/// [`StreamEvent::Finish`].
82///
83/// # Behavior
84///
85/// 1. Creates a temporary HTTP client with configured timeout
86/// 2. Builds message array (system prompt + user prompt)
87/// 3. Converts tools to the wire format if provided
88/// 4. Makes an HTTP POST request to the path the configured
89///    [`ApiProtocol`](crate::ApiProtocol) selects
90/// 5. Parses Server-Sent Events (SSE) response stream
91/// 6. Forwards each text and reasoning fragment as it arrives, and assembles tool calls,
92///    whose arguments are not valid JSON until the last fragment lands
93/// 7. Returns stream that yields events as they arrive, ending with `Finish`
94///
95/// # Error Handling
96///
97/// This function can return errors for:
98/// - HTTP client creation failures
99/// - Network errors during the request
100/// - API errors (authentication, invalid model, rate limits, etc.)
101/// - SSE parsing errors
102/// - JSON deserialization errors
103///
104/// # Performance Notes
105///
106/// - Creates a new HTTP client for each call (consider using `Client` for repeated queries)
107/// - Timeout is configurable via `AgentOptions::timeout` (default: 120 seconds)
108/// - Streaming begins immediately; no buffering of the full response
109///
110/// # Examples
111///
112/// ## Basic Usage
113///
114/// ```rust,no_run
115/// use open_agent::{query, AgentOptions, ContentBlock, FinishReason, StreamEvent};
116/// use futures::StreamExt;
117///
118/// #[tokio::main]
119/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
120///     let options = AgentOptions::builder()
121///         .system_prompt("You are a helpful assistant")
122///         .model("gpt-4")
123///         .api_key("sk-...")
124///         .build()?;
125///
126///     let mut stream = query("What's the capital of France?", &options).await?;
127///
128///     while let Some(event) = stream.next().await {
129///         match event? {
130///             StreamEvent::Block(ContentBlock::Text(text)) => print!("{}", text.text),
131///             StreamEvent::Finish(FinishReason::Length) => {
132///                 eprintln!("response truncated at the token cap");
133///             }
134///             _ => {}
135///         }
136///     }
137///
138///     Ok(())
139/// }
140/// ```
141///
142/// ## With Tools
143///
144/// ```rust,no_run
145/// use open_agent::{query, AgentOptions, Tool, ContentBlock, StreamEvent};
146/// use futures::StreamExt;
147/// use serde_json::json;
148///
149/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
150/// let calculator = Tool::new(
151///     "calculator",
152///     "Performs calculations",
153///     json!({"type": "object"}),
154///     |input| Box::pin(async move { Ok(json!({"result": 42})) })
155/// );
156///
157/// let options = AgentOptions::builder()
158///     .model("gpt-4")
159///     .api_key("sk-...")
160///     .tools(vec![calculator])
161///     .build()?;
162///
163/// let mut stream = query("Calculate 2+2", &options).await?;
164///
165/// while let Some(event) = stream.next().await {
166///     match event?.into_block() {
167///         Some(ContentBlock::ToolUse(tool_use)) => {
168///             println!("Model wants to use: {}", tool_use.name());
169///             // Note: You'll need to manually execute tools and continue
170///             // the conversation. For automatic execution, use Client.
171///         }
172///         Some(ContentBlock::Text(text)) => print!("{}", text.text),
173///         _ => {}
174///     }
175/// }
176/// # Ok(())
177/// # }
178/// ```
179///
180/// ## Error Handling
181///
182/// ```rust,no_run
183/// use open_agent::{query, AgentOptions};
184/// use futures::StreamExt;
185///
186/// # async fn example() {
187/// let options = AgentOptions::builder()
188///     .model("gpt-4")
189///     .api_key("invalid-key")
190///     .build()
191///     .unwrap();
192///
193/// match query("Hello", &options).await {
194///     Ok(mut stream) => {
195///         while let Some(result) = stream.next().await {
196///             match result {
197///                 Ok(event) => println!("Event: {:?}", event),
198///                 Err(e) => {
199///                     eprintln!("Stream error: {}", e);
200///                     break;
201///                 }
202///             }
203///         }
204///     }
205///     Err(e) => eprintln!("Query failed: {}", e),
206/// }
207/// # }
208/// ```
209pub async fn query(prompt: &str, options: &AgentOptions) -> Result<EventStream> {
210    // Create HTTP client with configured timeout
211    // The timeout applies to the entire request, not individual chunks
212    let client = reqwest::Client::builder()
213        .timeout(Duration::from_secs(options.timeout()))
214        .build()
215        .map_err(Error::Http)?;
216
217    // Build messages array for the API request
218    // OpenAI format expects an array of message objects with role and content
219    let mut messages = Vec::new();
220
221    // Add system prompt if provided
222    // System prompts set the assistant's behavior and context
223    if !options.system_prompt().is_empty() {
224        messages.push(OpenAIMessage {
225            role: "system".to_string(),
226            content: Some(OpenAIContent::Text(options.system_prompt().to_string())),
227            tool_calls: None,
228            tool_call_id: None,
229        });
230    }
231
232    // Add user prompt
233    // This is the actual query from the user
234    messages.push(OpenAIMessage {
235        role: "user".to_string(),
236        content: Some(OpenAIContent::Text(prompt.to_string())),
237        tool_calls: None,
238        tool_call_id: None,
239    });
240
241    // Convert tools to OpenAI format if any are provided
242    // Tools are described using JSON Schema for parameter validation
243    let tools = if !options.tools().is_empty() {
244        Some(
245            options
246                .tools()
247                .iter()
248                .map(|t| t.to_openai_format())
249                .collect(),
250        )
251    } else {
252        None
253    };
254
255    // Build the OpenAI-compatible request payload
256    // stream=true enables Server-Sent Events for incremental responses
257    let request = OpenAIRequest {
258        model: options.model().to_string(),
259        messages,
260        stream: true, // Critical: enables SSE streaming
261        max_tokens: options.max_tokens(),
262        temperature: options.temperature(),
263        tools,
264    };
265
266    stream_request(&client, options, &request).await
267}