Skip to main content

rig_agent/
completion.rs

1//! High-level prompting traits and runtime errors for the classic agent runtime.
2
3use serde::de::DeserializeOwned;
4use thiserror::Error;
5
6use rig_core::{
7    memory::MemoryError,
8    wasm_compat::{WasmCompatSend, WasmCompatSync},
9};
10
11pub use rig_core::completion::*;
12
13/// Errors from classic agent prompting.
14#[derive(Debug, Error)]
15pub enum PromptError {
16    /// A provider completion failed.
17    #[error("CompletionError: {0}")]
18    CompletionError(#[from] CompletionError),
19
20    /// Conversation memory failed to load or persist history.
21    #[error("MemoryError: {0}")]
22    MemoryError(#[from] MemoryError),
23
24    /// The run exhausted its total model-call budget.
25    #[error("MaxTurnsError: reached max turns limit: {max_turns}")]
26    MaxTurnsError {
27        /// Configured total model-call budget.
28        max_turns: usize,
29        /// Canonical history available when the budget was exhausted.
30        chat_history: Box<Vec<Message>>,
31        /// Prompt for the call that could not be dispatched.
32        prompt: Box<Message>,
33    },
34
35    /// A prompting loop was cancelled.
36    #[error("PromptCancelled: {reason}")]
37    PromptCancelled {
38        /// Canonical history available at cancellation.
39        chat_history: Vec<Message>,
40        /// Human-readable cancellation reason.
41        reason: String,
42    },
43
44    /// The model attempted to call a tool unavailable for the current turn.
45    #[error(
46        "UnknownToolCall: model attempted to call unknown or disallowed tool `{tool_name}`. Available tools: {available_tools:?}. Allowed tools for this turn: {allowed_tools:?}"
47    )]
48    UnknownToolCall {
49        /// Tool name emitted by the model.
50        tool_name: String,
51        /// Tools registered on the runtime.
52        available_tools: Vec<String>,
53        /// Exact immutable set allowed for this turn.
54        allowed_tools: Vec<String>,
55        /// Canonical history available at failure.
56        chat_history: Box<Vec<Message>>,
57    },
58}
59
60/// Forwards the `provider_response_*` accessor trio through the variant that
61/// wraps an error which itself exposes them.
62macro_rules! forward_provider_response_helpers {
63    ($err:ident, $variant:ident, $inner:literal) => {
64        impl $err {
65            #[doc = concat!("Returns the provider response body exposed by a wrapped ", $inner, ".")]
66            pub fn provider_response_body(&self) -> Option<&str> {
67                match self {
68                    Self::$variant(error) => error.provider_response_body(),
69                    _ => None,
70                }
71            }
72
73            #[doc = concat!("Parses the provider response body of a wrapped ", $inner, " as JSON when present.")]
74            pub fn provider_response_json(
75                &self,
76            ) -> Result<Option<serde_json::Value>, serde_json::Error> {
77                match self {
78                    Self::$variant(error) => error.provider_response_json(),
79                    _ => Ok(None),
80                }
81            }
82
83            #[doc = concat!("Returns the provider transport request id exposed by a wrapped ", $inner, " (rig#2314).")]
84            pub fn provider_request_id(&self) -> Option<&str> {
85                match self {
86                    Self::$variant(error) => error.provider_request_id(),
87                    _ => None,
88                }
89            }
90
91            #[doc = concat!("Returns the HTTP status exposed by a wrapped ", $inner, ".")]
92            pub fn provider_response_status(&self) -> Option<http::StatusCode> {
93                match self {
94                    Self::$variant(error) => error.provider_response_status(),
95                    _ => None,
96                }
97            }
98
99            #[doc = concat!("Returns the response headers exposed by a wrapped ", $inner, " — e.g. `Retry-After` on a 429 (rig#2210).")]
100            pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
101                match self {
102                    Self::$variant(error) => error.provider_response_headers(),
103                    _ => None,
104                }
105            }
106        }
107    };
108}
109
110forward_provider_response_helpers!(PromptError, CompletionError, "completion error");
111forward_provider_response_helpers!(StructuredOutputError, PromptError, "prompt error");
112
113impl PromptError {
114    pub(crate) fn prompt_cancelled(
115        chat_history: impl IntoIterator<Item = Message>,
116        reason: impl Into<String>,
117    ) -> Self {
118        Self::PromptCancelled {
119            chat_history: chat_history.into_iter().collect(),
120            reason: reason.into(),
121        }
122    }
123}
124
125/// Errors returned by typed structured prompting.
126#[derive(Debug, Error)]
127pub enum StructuredOutputError {
128    /// The underlying classic run failed.
129    #[error("PromptError: {0}")]
130    PromptError(#[from] Box<PromptError>),
131    /// The accepted response could not be deserialized.
132    #[error("DeserializationError: {0}")]
133    DeserializationError(#[from] serde_json::Error),
134    /// The model returned no accepted content.
135    #[error("EmptyResponse: model returned no content")]
136    EmptyResponse,
137}
138
139/// High-level one-shot prompting for the classic runtime.
140pub trait Prompt: WasmCompatSend + WasmCompatSync {
141    /// Send a prompt and return accepted assistant text after runtime orchestration.
142    fn prompt(
143        &self,
144        prompt: impl Into<Message> + WasmCompatSend,
145    ) -> impl std::future::IntoFuture<Output = Result<String, PromptError>, IntoFuture: WasmCompatSend>;
146}
147
148/// High-level prompting with caller-owned canonical chat history.
149pub trait Chat: WasmCompatSend + WasmCompatSync {
150    /// Execute one turn and append only committed messages to `chat_history`.
151    fn chat(
152        &self,
153        prompt: impl Into<Message> + WasmCompatSend,
154        chat_history: &mut Vec<Message>,
155    ) -> impl std::future::Future<Output = Result<String, PromptError>> + WasmCompatSend;
156}
157
158/// High-level typed structured prompting for the classic runtime.
159pub trait TypedPrompt: WasmCompatSend + WasmCompatSync {
160    /// Request type returned for one target output type.
161    type TypedRequest<T>: std::future::IntoFuture<Output = Result<T, StructuredOutputError>>
162    where
163        T: schemars::JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
164
165    /// Send a prompt and deserialize the accepted structured response as `T`.
166    fn prompt_typed<T>(&self, prompt: impl Into<Message> + WasmCompatSend) -> Self::TypedRequest<T>
167    where
168        T: schemars::JsonSchema + DeserializeOwned + WasmCompatSend;
169}
170
171#[cfg(test)]
172mod provider_response_tests {
173    use rig_core::{ProviderResponseError, http_client};
174
175    use super::*;
176
177    #[test]
178    fn prompt_error_forwards_provider_response_to_completion_error() {
179        let body = r#"{"error":{"message":"boom"}}"#;
180        let inner =
181            CompletionError::from_http_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
182        let error = PromptError::CompletionError(inner);
183
184        assert_eq!(
185            error.provider_response_status(),
186            Some(http::StatusCode::SERVICE_UNAVAILABLE),
187        );
188        assert_eq!(error.provider_response_body(), Some(body));
189        assert_eq!(
190            error
191                .provider_response_json()
192                .expect("valid json")
193                .expect("present json")["error"]["message"],
194            "boom",
195        );
196    }
197
198    #[test]
199    fn prompt_error_provider_response_helpers_forward_http_status_and_body() {
200        let body = r#"{"error":{"message":"unauthorized"}}"#;
201        let error = PromptError::CompletionError(CompletionError::HttpError(
202            http_client::Error::InvalidStatusCodeWithMessage(
203                http::StatusCode::UNAUTHORIZED,
204                body.to_string(),
205            ),
206        ));
207
208        assert_eq!(error.provider_response_body(), Some(body));
209        assert_eq!(
210            error.provider_response_status(),
211            Some(http::StatusCode::UNAUTHORIZED)
212        );
213        assert_eq!(
214            error.provider_response_json().expect("valid JSON body"),
215            Some(serde_json::json!({
216                "error": { "message": "unauthorized" }
217            }))
218        );
219    }
220
221    #[test]
222    fn prompt_error_provider_response_helpers_forward_wrapped_completion_error() {
223        let body = r#"{"error":{"code":"invalid_request","message":"bad input"}}"#;
224        let error = PromptError::CompletionError(CompletionError::ProviderResponse(
225            ProviderResponseError::without_status(body),
226        ));
227
228        assert_eq!(error.provider_response_body(), Some(body));
229        assert_eq!(error.provider_response_status(), None);
230        // rig#2314: the transport request id forwards through the wrapper too.
231        assert_eq!(error.provider_request_id(), None);
232        assert_eq!(
233            error.provider_response_json().expect("valid JSON body"),
234            Some(serde_json::json!({
235                "error": {
236                    "code": "invalid_request",
237                    "message": "bad input"
238                }
239            }))
240        );
241    }
242
243    /// rig#2210: the response headers forward through both wrappers, so an
244    /// agent-level caller can back off on `Retry-After` without unwrapping to
245    /// the transport error by hand. Covered on both classifications, since
246    /// the two store the headers in different places.
247    #[test]
248    fn prompt_error_forwards_captured_response_headers() {
249        let mut headers = http::HeaderMap::new();
250        headers.insert(
251            http::header::RETRY_AFTER,
252            http::HeaderValue::from_static("20"),
253        );
254        let body = r#"{"error":{"message":"rate limited"}}"#;
255
256        for completion_error in [
257            // Contract provider: headers live on the ProviderResponse.
258            CompletionError::from_http_response_with_request_id(
259                http::StatusCode::TOO_MANY_REQUESTS,
260                body,
261                Some("req_abc".to_string()),
262            )
263            .with_response_headers(Some(Box::new(headers.clone()))),
264            // Contract-less provider: headers live on the transport error.
265            CompletionError::from_http_response(http::StatusCode::TOO_MANY_REQUESTS, body)
266                .with_response_headers(Some(Box::new(headers.clone()))),
267        ] {
268            let prompt_error = PromptError::CompletionError(completion_error);
269            assert_eq!(
270                prompt_error
271                    .provider_response_headers()
272                    .and_then(|headers| headers.get(http::header::RETRY_AFTER))
273                    .and_then(|value| value.to_str().ok()),
274                Some("20"),
275                "PromptError dropped the captured headers",
276            );
277
278            let structured = StructuredOutputError::PromptError(Box::new(prompt_error));
279            assert_eq!(
280                structured
281                    .provider_response_headers()
282                    .and_then(|headers| headers.get(http::header::RETRY_AFTER))
283                    .and_then(|value| value.to_str().ok()),
284                Some("20"),
285                "StructuredOutputError dropped the captured headers",
286            );
287        }
288    }
289
290    /// Variants that wrap no provider response report no headers.
291    #[test]
292    fn prompt_error_reports_no_headers_for_unrelated_variants() {
293        let error = PromptError::PromptCancelled {
294            chat_history: vec![Message::user("hi")],
295            reason: "cancelled".to_string(),
296        };
297        assert!(error.provider_response_headers().is_none());
298        assert!(
299            StructuredOutputError::EmptyResponse
300                .provider_response_headers()
301                .is_none()
302        );
303    }
304
305    /// rig#2314: a wrapped completion error's transport request id forwards
306    /// through `PromptError` (and, transitively, `StructuredOutputError`).
307    #[test]
308    fn prompt_error_forwards_the_provider_request_id() {
309        let error = PromptError::CompletionError(CompletionError::ProviderResponse(
310            ProviderResponseError::new(http::StatusCode::NOT_FOUND, "{}")
311                .with_provider_request_id(Some("req_failed_call".to_string())),
312        ));
313        assert_eq!(error.provider_request_id(), Some("req_failed_call"));
314    }
315
316    #[test]
317    fn prompt_error_provider_response_helpers_return_none_for_unrelated_variant() {
318        let error = PromptError::PromptCancelled {
319            chat_history: vec![Message::user("hi")],
320            reason: "cancelled".to_string(),
321        };
322
323        assert_eq!(error.provider_response_body(), None);
324        assert_eq!(error.provider_response_status(), None);
325        assert_eq!(
326            error
327                .provider_response_json()
328                .expect("no body is not an error"),
329            None
330        );
331    }
332
333    #[test]
334    fn structured_output_error_provider_response_helpers_forward_prompt_error() {
335        let body = r#"{"error":{"message":"bad input"}}"#;
336        let error = StructuredOutputError::PromptError(Box::new(PromptError::CompletionError(
337            CompletionError::ProviderResponse(ProviderResponseError::new(
338                http::StatusCode::BAD_REQUEST,
339                body,
340            )),
341        )));
342
343        assert_eq!(error.provider_response_body(), Some(body));
344        assert_eq!(
345            error.provider_response_status(),
346            Some(http::StatusCode::BAD_REQUEST)
347        );
348    }
349}