Skip to content

Commit a92bb9c

Browse files
committed
merge main
2 parents 7ca9531 + 0c82fc6 commit a92bb9c

19 files changed

Lines changed: 770 additions & 139 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ authors = ["Amazon Q CLI Team (q-cli@amazon.com)", "Chay Nabors (nabochay@amazon
88
edition = "2024"
99
homepage = "https://aws.amazon.com/q/"
1010
publish = false
11-
version = "1.13.2"
11+
version = "1.13.3"
1212
license = "MIT OR Apache-2.0"
1313

1414
[workspace.dependencies]

crates/chat-cli/src/api_client/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod error;
55
pub mod model;
66
mod opt_out;
77
pub mod profile;
8+
mod retry_classifier;
89
pub mod send_message_output;
910
use std::sync::Arc;
1011
use std::time::Duration;
@@ -164,6 +165,7 @@ impl ApiClient {
164165
.interceptor(UserAgentOverrideInterceptor::new())
165166
.app_name(app_name())
166167
.endpoint_url(endpoint.url())
168+
.retry_classifier(retry_classifier::QCliRetryClassifier::new())
167169
.stalled_stream_protection(stalled_stream_protection_config())
168170
.build(),
169171
));
@@ -177,6 +179,7 @@ impl ApiClient {
177179
.bearer_token_resolver(BearerResolver)
178180
.app_name(app_name())
179181
.endpoint_url(endpoint.url())
182+
.retry_classifier(retry_classifier::QCliRetryClassifier::new())
180183
.stalled_stream_protection(stalled_stream_protection_config())
181184
.build(),
182185
));
@@ -591,7 +594,9 @@ fn timeout_config(database: &Database) -> TimeoutConfig {
591594
}
592595

593596
fn retry_config() -> RetryConfig {
594-
RetryConfig::standard().with_max_attempts(1)
597+
RetryConfig::adaptive()
598+
.with_max_attempts(3)
599+
.with_max_backoff(Duration::from_secs(10))
595600
}
596601

597602
pub fn stalled_stream_protection_config() -> StalledStreamProtectionConfig {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
use std::fmt;
2+
3+
use aws_smithy_runtime_api::client::interceptors::context::InterceptorContext;
4+
use aws_smithy_runtime_api::client::retries::classifiers::{
5+
ClassifyRetry,
6+
RetryAction,
7+
RetryClassifierPriority,
8+
};
9+
use tracing::debug;
10+
11+
const MONTHLY_LIMIT_ERROR_MARKER: &str = "MONTHLY_REQUEST_COUNT";
12+
const HIGH_LOAD_ERROR_MESSAGE: &str =
13+
"Encountered unexpectedly high load when processing the request, please try again.";
14+
const SERVICE_UNAVAILABLE_EXCEPTION: &str = "ServiceUnavailableException";
15+
16+
#[derive(Debug, Default)]
17+
pub struct QCliRetryClassifier;
18+
19+
impl QCliRetryClassifier {
20+
pub fn new() -> Self {
21+
Self
22+
}
23+
24+
pub fn priority() -> RetryClassifierPriority {
25+
RetryClassifierPriority::run_after(RetryClassifierPriority::transient_error_classifier())
26+
}
27+
28+
fn extract_response_body(ctx: &InterceptorContext) -> Option<&str> {
29+
let bytes = ctx.response()?.body().bytes()?;
30+
std::str::from_utf8(bytes).ok()
31+
}
32+
33+
fn is_monthly_limit_error(body_str: &str) -> bool {
34+
let is_monthly_limit = body_str.contains(MONTHLY_LIMIT_ERROR_MARKER);
35+
debug!(
36+
"QCliRetryClassifier: Monthly limit error detected: {}",
37+
is_monthly_limit
38+
);
39+
is_monthly_limit
40+
}
41+
42+
fn is_service_overloaded_error(ctx: &InterceptorContext, body_str: &str) -> bool {
43+
let Some(resp) = ctx.response() else {
44+
return false;
45+
};
46+
47+
if resp.status().as_u16() != 500 {
48+
return false;
49+
}
50+
51+
let is_overloaded =
52+
body_str.contains(HIGH_LOAD_ERROR_MESSAGE) || body_str.contains(SERVICE_UNAVAILABLE_EXCEPTION);
53+
54+
debug!(
55+
"QCliRetryClassifier: Service overloaded error detected (status 500): {}",
56+
is_overloaded
57+
);
58+
is_overloaded
59+
}
60+
}
61+
62+
impl ClassifyRetry for QCliRetryClassifier {
63+
fn classify_retry(&self, ctx: &InterceptorContext) -> RetryAction {
64+
let Some(body_str) = Self::extract_response_body(ctx) else {
65+
return RetryAction::NoActionIndicated;
66+
};
67+
68+
if Self::is_monthly_limit_error(body_str) {
69+
return RetryAction::RetryForbidden;
70+
}
71+
72+
if Self::is_service_overloaded_error(ctx, body_str) {
73+
return RetryAction::throttling_error();
74+
}
75+
76+
RetryAction::NoActionIndicated
77+
}
78+
79+
fn name(&self) -> &'static str {
80+
"Q CLI Custom Retry Classifier"
81+
}
82+
83+
fn priority(&self) -> RetryClassifierPriority {
84+
Self::priority()
85+
}
86+
}
87+
88+
impl fmt::Display for QCliRetryClassifier {
89+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90+
write!(f, "QCliRetryClassifier")
91+
}
92+
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use aws_smithy_runtime_api::client::interceptors::context::{
97+
Input,
98+
InterceptorContext,
99+
};
100+
use aws_smithy_types::body::SdkBody;
101+
use http::Response;
102+
103+
use super::*;
104+
105+
#[test]
106+
fn test_monthly_limit_error_classification() {
107+
let classifier = QCliRetryClassifier::new();
108+
let mut ctx = InterceptorContext::new(Input::doesnt_matter());
109+
110+
let response_body = r#"{"__type":"ThrottlingException","message":"Maximum Request reached for this month.","reason":"MONTHLY_REQUEST_COUNT"}"#;
111+
let response = Response::builder()
112+
.status(400)
113+
.body(response_body)
114+
.unwrap()
115+
.map(SdkBody::from);
116+
117+
ctx.set_response(response.try_into().unwrap());
118+
119+
let result = classifier.classify_retry(&ctx);
120+
assert_eq!(result, RetryAction::RetryForbidden);
121+
}
122+
123+
#[test]
124+
fn test_service_unavailable_exception_classification() {
125+
let classifier = QCliRetryClassifier::new();
126+
let mut ctx = InterceptorContext::new(Input::doesnt_matter());
127+
128+
let response_body = r#"{"__type":"ServiceUnavailableException","message":"The service is temporarily unavailable. Please try again later."}"#;
129+
let response = Response::builder()
130+
.status(500)
131+
.body(response_body)
132+
.unwrap()
133+
.map(SdkBody::from);
134+
135+
ctx.set_response(response.try_into().unwrap());
136+
137+
let result = classifier.classify_retry(&ctx);
138+
assert_eq!(result, RetryAction::throttling_error());
139+
}
140+
141+
#[test]
142+
fn test_high_load_error_classification() {
143+
let classifier = QCliRetryClassifier::new();
144+
let mut ctx = InterceptorContext::new(Input::doesnt_matter());
145+
146+
let response_body =
147+
r#"{"error": "Encountered unexpectedly high load when processing the request, please try again."}"#;
148+
let response = Response::builder()
149+
.status(500)
150+
.body(response_body)
151+
.unwrap()
152+
.map(SdkBody::from);
153+
154+
ctx.set_response(response.try_into().unwrap());
155+
156+
let result = classifier.classify_retry(&ctx);
157+
assert_eq!(result, RetryAction::throttling_error());
158+
}
159+
160+
#[test]
161+
fn test_500_error_without_specific_message_not_retried() {
162+
let classifier = QCliRetryClassifier::new();
163+
let mut ctx = InterceptorContext::new(Input::doesnt_matter());
164+
165+
let response_body = r#"{"__type":"InternalServerException","message":"Some other error"}"#;
166+
let response = Response::builder()
167+
.status(500)
168+
.body(response_body)
169+
.unwrap()
170+
.map(SdkBody::from);
171+
172+
ctx.set_response(response.try_into().unwrap());
173+
174+
let result = classifier.classify_retry(&ctx);
175+
assert_eq!(result, RetryAction::NoActionIndicated);
176+
}
177+
178+
#[test]
179+
fn test_no_action_for_other_status_codes() {
180+
let classifier = QCliRetryClassifier::new();
181+
let mut ctx = InterceptorContext::new(Input::doesnt_matter());
182+
183+
let response = Response::builder()
184+
.status(400)
185+
.body("Bad Request")
186+
.unwrap()
187+
.map(SdkBody::from);
188+
189+
ctx.set_response(response.try_into().unwrap());
190+
191+
let result = classifier.classify_retry(&ctx);
192+
assert_eq!(result, RetryAction::NoActionIndicated);
193+
}
194+
}

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,17 @@ impl Agent {
305305
}
306306
}
307307

308+
/// Result of evaluating tool permissions, indicating whether a tool should be allowed,
309+
/// require user confirmation, or be denied with specific reasons.
308310
#[derive(Debug, PartialEq)]
309311
pub enum PermissionEvalResult {
312+
/// Tool is allowed to execute without user confirmation
310313
Allow,
314+
/// Tool requires user confirmation before execution
311315
Ask,
312-
Deny,
316+
/// Denial with specific reasons explaining why the tool was denied
317+
/// Tools are free to overload what these reasons are
318+
Deny(Vec<String>),
313319
}
314320

315321
#[derive(Clone, Default, Debug)]

crates/chat-cli/src/cli/agent/wrapper_types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ pub fn alias_schema(generator: &mut SchemaGenerator) -> Schema {
5353

5454
/// The name of the tool to be configured
5555
#[derive(Debug, Clone, Serialize, Deserialize, Eq, Hash, PartialEq, JsonSchema)]
56-
pub struct ToolSettingTarget(String);
56+
pub struct ToolSettingTarget(pub String);
5757

5858
impl Deref for ToolSettingTarget {
5959
type Target = String;

crates/chat-cli/src/cli/chat/consts.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ pub const AGENT_FORMAT_TOOLS_DOC_URL: &str =
2626

2727
pub const AGENT_MIGRATION_DOC_URL: &str =
2828
"https://github.com/aws/amazon-q-developer-cli/blob/main/docs/legacy-profile-to-agent-migration.md";
29+
30+
// The environment variable name where we set additional metadata for the AWS CLI user agent.
31+
pub const USER_AGENT_ENV_VAR: &str = "AWS_EXECUTION_ENV";
32+
pub const USER_AGENT_APP_NAME: &str = "AmazonQ-For-CLI";
33+
pub const USER_AGENT_VERSION_KEY: &str = "Version";
34+
pub const USER_AGENT_VERSION_VALUE: &str = env!("CARGO_PKG_VERSION");

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::collections::{
66
use std::io::Write;
77
use std::sync::atomic::Ordering;
88

9+
use chrono::Utc;
910
use crossterm::style::Color;
1011
use crossterm::{
1112
execute,
@@ -205,7 +206,7 @@ impl ConversationState {
205206
let Prompt { role, content } = prompt;
206207
match role {
207208
crate::mcp_client::Role::User => {
208-
let user_msg = UserMessage::new_prompt(content.to_string());
209+
let user_msg = UserMessage::new_prompt(content.to_string(), None);
209210
candidate_user.replace(user_msg);
210211
},
211212
crate::mcp_client::Role::Assistant => {
@@ -248,7 +249,7 @@ impl ConversationState {
248249
input
249250
};
250251

251-
let msg = UserMessage::new_prompt(input);
252+
let msg = UserMessage::new_prompt(input, Some(Utc::now()));
252253
self.next_message = Some(msg);
253254
}
254255

@@ -337,14 +338,19 @@ impl ConversationState {
337338

338339
pub fn add_tool_results_with_images(&mut self, tool_results: Vec<ToolUseResult>, images: Vec<ImageBlock>) {
339340
debug_assert!(self.next_message.is_none());
340-
self.next_message = Some(UserMessage::new_tool_use_results_with_images(tool_results, images));
341+
self.next_message = Some(UserMessage::new_tool_use_results_with_images(
342+
tool_results,
343+
images,
344+
Some(Utc::now()),
345+
));
341346
}
342347

343348
/// Sets the next user message with "cancelled" tool results.
344349
pub fn abandon_tool_use(&mut self, tools_to_be_abandoned: &[QueuedTool], deny_input: String) {
345350
self.next_message = Some(UserMessage::new_cancelled_tool_uses(
346351
Some(deny_input),
347352
tools_to_be_abandoned.iter().map(|t| t.id.as_str()),
353+
Some(Utc::now()),
348354
));
349355
}
350356

@@ -519,7 +525,7 @@ impl ConversationState {
519525
}
520526

521527
let conv_state = self.backend_conversation_state(os, false, &mut vec![]).await?;
522-
let mut summary_message = Some(UserMessage::new_prompt(summary_content.clone()));
528+
let mut summary_message = Some(UserMessage::new_prompt(summary_content.clone(), None));
523529

524530
// Create the history according to the passed compact strategy.
525531
let mut history = conv_state.history.cloned().collect::<VecDeque<_>>();
@@ -548,7 +554,7 @@ impl ConversationState {
548554
Ok(FigConversationState {
549555
conversation_id: Some(self.conversation_id.clone()),
550556
user_input_message: summary_message
551-
.unwrap_or(UserMessage::new_prompt(summary_content)) // should not happen
557+
.unwrap_or(UserMessage::new_prompt(summary_content, None)) // should not happen
552558
.into_user_input_message(self.model.as_ref().map(|m| m.model_id.clone()), &tools),
553559
history: Some(flatten_history(history.iter())),
554560
})
@@ -631,7 +637,7 @@ impl ConversationState {
631637

632638
if !context_content.is_empty() {
633639
self.context_message_length = Some(context_content.len());
634-
let user = UserMessage::new_prompt(context_content);
640+
let user = UserMessage::new_prompt(context_content, None);
635641
let assistant = AssistantMessage::new_response(None, "I will fully incorporate this information when generating my responses, and explicitly acknowledge relevant parts of the summary when answering questions.".into());
636642
(
637643
Some(vec![HistoryEntry {
@@ -857,6 +863,7 @@ fn enforce_conversation_invariants(
857863
debug!("abandoning tool results");
858864
*next_message = Some(UserMessage::new_prompt(
859865
"The conversation history has overflowed, clearing state".to_string(),
866+
None,
860867
));
861868
}
862869
},
@@ -910,6 +917,7 @@ fn enforce_conversation_invariants(
910917
*user_msg = UserMessage::new_cancelled_tool_uses(
911918
user_msg.prompt().map(|p| p.to_string()),
912919
tool_uses.iter().map(|t| t.id.as_str()),
920+
None,
913921
);
914922
}
915923
}

0 commit comments

Comments
 (0)