1use crate::hooks::{HookId, HookPoint, HookReasonCode};
4use crate::tool_catalog::ToolUnavailableReason;
5use crate::types::SessionId;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq)]
9#[non_exhaustive]
10pub enum LlmFailureReason {
11 RateLimited {
12 retry_after: Option<std::time::Duration>,
13 },
14 ContextExceeded {
15 max: u32,
16 requested: u32,
17 },
18 AuthError,
19 InvalidModel(String),
20 ProviderError(LlmProviderError),
21 NetworkTimeout {
23 duration_ms: u64,
24 },
25 CallTimeout {
27 duration_ms: u64,
28 },
29}
30
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum LlmProviderErrorKind {
35 InvalidRequest,
36 ContentFiltered,
37 ServerError,
38 ServerOverloaded,
39 ConnectionReset,
40 Unknown,
41 StreamParseError,
42 IncompleteResponse,
43}
44
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LlmProviderErrorRetryability {
49 Retryable,
50 NonRetryable,
51}
52
53impl LlmProviderErrorRetryability {
54 pub fn is_retryable(self) -> bool {
55 matches!(self, Self::Retryable)
56 }
57}
58
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct LlmProviderError {
62 pub kind: LlmProviderErrorKind,
63 pub retryability: LlmProviderErrorRetryability,
64 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
65 pub details: serde_json::Value,
66}
67
68impl LlmProviderError {
69 pub fn new(
70 kind: LlmProviderErrorKind,
71 retryability: LlmProviderErrorRetryability,
72 details: serde_json::Value,
73 ) -> Self {
74 Self {
75 kind,
76 retryability,
77 details,
78 }
79 }
80
81 pub fn retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
82 Self::new(kind, LlmProviderErrorRetryability::Retryable, details)
83 }
84
85 pub fn non_retryable(kind: LlmProviderErrorKind, details: serde_json::Value) -> Self {
86 Self::new(kind, LlmProviderErrorRetryability::NonRetryable, details)
87 }
88
89 pub fn is_retryable(&self) -> bool {
90 self.retryability.is_retryable()
91 }
92}
93
94#[derive(Debug, Clone, thiserror::Error, PartialEq)]
96pub enum ToolValidationError {
97 #[error("Tool not found: {name}")]
99 NotFound { name: String },
100 #[error("Invalid arguments for tool '{name}': {reason}")]
102 InvalidArguments { name: String, reason: String },
103}
104
105impl ToolValidationError {
106 pub fn not_found(name: impl Into<String>) -> Self {
107 Self::NotFound { name: name.into() }
108 }
109 pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
110 Self::InvalidArguments {
111 name: name.into(),
112 reason: reason.into(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, thiserror::Error)]
119pub enum ToolError {
120 #[error("Tool not found: {name}")]
122 NotFound { name: String },
123
124 #[error("Tool '{name}' is currently unavailable: {reason}")]
126 Unavailable {
127 name: String,
128 reason: ToolUnavailableReason,
129 },
130
131 #[error("Invalid arguments for tool '{name}': {reason}")]
133 InvalidArguments { name: String, reason: String },
134
135 #[error("Tool execution failed: {message}")]
137 ExecutionFailed { message: String },
138
139 #[error("Tool execution failed: {message}")]
141 ExecutionFailedWithData {
142 message: String,
143 data: serde_json::Value,
144 },
145
146 #[error("Tool '{name}' timed out after {timeout_ms}ms")]
148 Timeout { name: String, timeout_ms: u64 },
149
150 #[error("Tool '{name}' is not allowed by policy")]
152 AccessDenied { name: String },
153
154 #[error("{0}")]
156 Other(String),
157
158 #[error("Callback pending for tool '{tool_name}'")]
164 CallbackPending {
165 tool_name: String,
166 args: serde_json::Value,
167 },
168}
169
170impl ToolError {
171 pub fn error_code(&self) -> &'static str {
172 match self {
173 Self::NotFound { .. } => "tool_not_found",
174 Self::Unavailable { .. } => "tool_unavailable",
175 Self::InvalidArguments { .. } => "invalid_arguments",
176 Self::ExecutionFailed { .. } | Self::ExecutionFailedWithData { .. } => {
177 "execution_failed"
178 }
179 Self::Timeout { .. } => "timeout",
180 Self::AccessDenied { .. } => "access_denied",
181 Self::Other(_) => "tool_error",
182 Self::CallbackPending { .. } => "callback_pending",
183 }
184 }
185
186 pub fn to_error_payload(&self) -> serde_json::Value {
187 let mut payload = serde_json::json!({
188 "error": self.error_code(),
189 "message": self.to_string(),
190 });
191 if let Some(data) = self.structured_data() {
192 payload["data"] = data;
193 }
194 payload
195 }
196
197 #[must_use]
205 pub fn to_transcript_content(&self) -> String {
206 self.to_error_payload().to_string()
207 }
208
209 pub fn not_found(name: impl Into<String>) -> Self {
210 Self::NotFound { name: name.into() }
211 }
212 pub fn unavailable(name: impl Into<String>, reason: ToolUnavailableReason) -> Self {
213 Self::Unavailable {
214 name: name.into(),
215 reason,
216 }
217 }
218 pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
219 Self::InvalidArguments {
220 name: name.into(),
221 reason: reason.into(),
222 }
223 }
224 pub fn execution_failed(message: impl Into<String>) -> Self {
225 Self::ExecutionFailed {
226 message: message.into(),
227 }
228 }
229 pub fn execution_failed_with_data(message: impl Into<String>, data: serde_json::Value) -> Self {
230 Self::ExecutionFailedWithData {
231 message: message.into(),
232 data,
233 }
234 }
235 pub fn structured_data(&self) -> Option<serde_json::Value> {
236 match self {
237 Self::ExecutionFailedWithData { data, .. } => Some(data.clone()),
238 _ => None,
239 }
240 }
241 pub fn timeout(name: impl Into<String>, timeout_ms: u64) -> Self {
242 Self::Timeout {
243 name: name.into(),
244 timeout_ms,
245 }
246 }
247 pub fn access_denied(name: impl Into<String>) -> Self {
248 Self::AccessDenied { name: name.into() }
249 }
250 pub fn other(message: impl Into<String>) -> Self {
251 Self::Other(message.into())
252 }
253
254 pub fn callback_pending(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
256 Self::CallbackPending {
257 tool_name: tool_name.into(),
258 args,
259 }
260 }
261
262 pub fn is_callback_pending(&self) -> bool {
264 matches!(self, Self::CallbackPending { .. })
265 }
266
267 pub fn as_callback_pending(&self) -> Option<(&str, &serde_json::Value)> {
269 match self {
270 Self::CallbackPending { tool_name, args } => Some((tool_name, args)),
271 _ => None,
272 }
273 }
274}
275
276impl From<String> for ToolError {
277 fn from(s: String) -> Self {
278 Self::Other(s)
279 }
280}
281impl From<&str> for ToolError {
282 fn from(s: &str) -> Self {
283 Self::Other(s.to_string())
284 }
285}
286
287#[derive(Debug, thiserror::Error)]
289#[non_exhaustive]
290pub enum AgentError {
291 #[error("LLM error ({provider}): {message}")]
292 Llm {
293 provider: &'static str,
294 reason: LlmFailureReason,
295 message: String,
296 },
297 #[error("Storage error: {0}")]
298 StoreError(String),
299 #[error("Tool error: {error}")]
305 Tool { error: ToolError },
306 #[error("MCP error: {0}")]
307 McpError(String),
308 #[error("Session not found: {0}")]
309 SessionNotFound(SessionId),
310 #[error("Token budget exceeded: used {used}, limit {limit}")]
311 TokenBudgetExceeded { used: u64, limit: u64 },
312 #[error("Time budget exceeded: {elapsed_secs}s > {limit_secs}s")]
313 TimeBudgetExceeded { elapsed_secs: u64, limit_secs: u64 },
314 #[error("Tool call budget exceeded: {count} calls > {limit} limit")]
315 ToolCallBudgetExceeded { count: usize, limit: usize },
316 #[error("Max tokens reached on turn {turn}, partial output: {partial}")]
317 MaxTokensReached { turn: u32, partial: String },
318 #[error("Content filtered on turn {turn}")]
319 ContentFiltered { turn: u32 },
320 #[error("Max turns reached: {turns}")]
321 MaxTurnsReached { turns: u32 },
322 #[error("Run was cancelled")]
323 Cancelled,
324 #[error("Invalid state transition: {from} -> {to}")]
325 InvalidStateTransition { from: String, to: String },
326 #[error("Operation not found: {0}")]
327 OperationNotFound(String),
328 #[error("Depth limit exceeded: {depth} > {max}")]
329 DepthLimitExceeded { depth: u32, max: u32 },
330 #[error("Concurrency limit exceeded")]
331 ConcurrencyLimitExceeded,
332 #[error("Configuration error: {0}")]
333 ConfigError(String),
334 #[error("Invalid tool in access policy: {tool}")]
335 InvalidToolAccess { tool: String },
336 #[error("Skill resolution failed for {skill_key:?}: {reason}")]
337 SkillResolutionFailed {
338 skill_key: Option<crate::skills::SkillKey>,
339 reason: Box<crate::event::SkillResolutionFailureReason>,
340 },
341 #[error("Internal error: {0}")]
342 InternalError(String),
343
344 #[error("Build error: {0}")]
346 BuildError(String),
347
348 #[error("Session identity already active: {0}")]
350 SessionIdentityInUse(SessionId),
351
352 #[error("Connection `{binding_key}` requires re-authentication: {message}")]
359 AuthReauthRequired {
360 binding_key: String,
361 message: String,
362 },
363
364 #[error("Callback pending for tool '{tool_name}'")]
366 CallbackPending {
367 tool_name: String,
368 args: serde_json::Value,
369 },
370
371 #[error("Structured output validation failed after {attempts} attempts: {reason}")]
373 StructuredOutputValidationFailed {
374 attempts: u32,
375 reason: String,
376 last_output: String,
377 },
378
379 #[error("Invalid output schema: {0}")]
381 InvalidOutputSchema(String),
382
383 #[error("Hook '{hook_id}' denied at {point:?}: {reason_code:?} - {message}")]
384 HookDenied {
385 hook_id: HookId,
386 point: HookPoint,
387 reason_code: HookReasonCode,
388 message: String,
389 payload: Option<serde_json::Value>,
390 },
391
392 #[error("Hook '{hook_id}' timed out after {timeout_ms}ms")]
393 HookTimeout { hook_id: HookId, timeout_ms: u64 },
394
395 #[error("Hook execution failed for '{hook_id}': {reason}")]
396 HookExecutionFailed { hook_id: HookId, reason: String },
397
398 #[error("Hook configuration invalid: {reason}")]
399 HookConfigInvalid { reason: String },
400
401 #[error("Terminal failure: {outcome:?} ({cause_kind:?}): {message}")]
403 TerminalFailure {
404 outcome: crate::turn_execution_authority::TurnTerminalOutcome,
405 cause_kind: crate::turn_execution_authority::TurnTerminalCauseKind,
406 message: String,
407 },
408
409 #[error("no pending boundary for resume")]
414 NoPendingBoundary,
415
416 #[error("durable session snapshot synchronization is not supported by this session agent")]
421 DurableSnapshotSyncUnsupported,
422}
423
424impl AgentError {
425 pub fn tool(error: ToolError) -> Self {
428 Self::Tool { error }
429 }
430
431 pub fn tool_error_code(&self) -> Option<&'static str> {
438 match self {
439 Self::Tool { error } => Some(error.error_code()),
440 _ => None,
441 }
442 }
443
444 pub fn llm(
445 provider: &'static str,
446 reason: LlmFailureReason,
447 message: impl Into<String>,
448 ) -> Self {
449 Self::Llm {
450 provider,
451 reason,
452 message: message.into(),
453 }
454 }
455
456 pub fn llm_empty_response(provider: &'static str) -> Self {
457 Self::llm(
458 provider,
459 LlmFailureReason::ProviderError(LlmProviderError::retryable(
460 LlmProviderErrorKind::IncompleteResponse,
461 serde_json::json!({
462 "reason": "provider completed without user-visible text, images, or tool calls"
463 }),
464 )),
465 "LLM completed without user-visible text, images, or tool calls",
466 )
467 }
468
469 pub fn is_graceful(&self) -> bool {
470 matches!(
471 self,
472 Self::TokenBudgetExceeded { .. }
473 | Self::TimeBudgetExceeded { .. }
474 | Self::ToolCallBudgetExceeded { .. }
475 | Self::MaxTurnsReached { .. }
476 )
477 }
478 pub fn is_rate_limited(&self) -> bool {
479 matches!(
480 self,
481 Self::Llm {
482 reason: LlmFailureReason::RateLimited { .. },
483 ..
484 }
485 )
486 }
487
488 pub fn retry_after_hint(&self) -> Option<std::time::Duration> {
489 match self {
490 Self::Llm {
491 reason: LlmFailureReason::RateLimited { retry_after },
492 ..
493 } => *retry_after,
494 _ => None,
495 }
496 }
497
498 pub fn is_recoverable(&self) -> bool {
499 match self {
500 Self::Llm { reason, .. } => match reason {
501 LlmFailureReason::RateLimited { .. } => true,
502 LlmFailureReason::NetworkTimeout { .. } => true,
503 LlmFailureReason::CallTimeout { .. } => true,
504 LlmFailureReason::ProviderError(provider_error) => provider_error.is_retryable(),
505 _ => false,
506 },
507 _ => false,
508 }
509 }
510}
511
512pub fn store_error(err: impl std::fmt::Display) -> AgentError {
513 AgentError::StoreError(store_error_message(err))
514}
515pub fn invalid_session_id(err: impl std::fmt::Display) -> AgentError {
516 AgentError::StoreError(invalid_session_id_message(err))
517}
518pub fn store_error_message(err: impl std::fmt::Display) -> String {
519 err.to_string()
520}
521pub fn invalid_session_id_message(err: impl std::fmt::Display) -> String {
522 format!("Invalid session ID: {err}")
523}
524
525#[cfg(test)]
526#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_network_timeout_is_recoverable() {
532 let err = AgentError::llm(
533 "anthropic",
534 LlmFailureReason::NetworkTimeout { duration_ms: 30000 },
535 "network timeout after 30s",
536 );
537 assert!(err.is_recoverable());
538 }
539
540 #[test]
541 fn test_call_timeout_is_recoverable() {
542 let err = AgentError::llm(
543 "anthropic",
544 LlmFailureReason::CallTimeout { duration_ms: 45000 },
545 "call timeout after 45s",
546 );
547 assert!(err.is_recoverable());
548 }
549
550 #[test]
551 fn test_network_timeout_typed_mapping() {
552 let reason = LlmFailureReason::NetworkTimeout { duration_ms: 5000 };
553 match reason {
554 LlmFailureReason::NetworkTimeout { duration_ms } => {
555 assert_eq!(duration_ms, 5000);
556 }
557 _ => panic!("expected NetworkTimeout"),
558 }
559 }
560
561 #[test]
562 fn test_call_timeout_typed_mapping() {
563 let reason = LlmFailureReason::CallTimeout { duration_ms: 60000 };
564 match reason {
565 LlmFailureReason::CallTimeout { duration_ms } => {
566 assert_eq!(duration_ms, 60000);
567 }
568 _ => panic!("expected CallTimeout"),
569 }
570 }
571
572 #[test]
573 fn test_timeout_variants_are_distinct() {
574 let net = LlmFailureReason::NetworkTimeout { duration_ms: 1000 };
575 let call = LlmFailureReason::CallTimeout { duration_ms: 1000 };
576 assert_ne!(net, call);
577 }
578
579 #[test]
580 fn test_auth_error_not_recoverable() {
581 let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
582 assert!(!err.is_recoverable());
583 }
584
585 #[test]
586 fn provider_error_uses_typed_retryability_for_recovery() {
587 let err = AgentError::llm(
588 "anthropic",
589 LlmFailureReason::ProviderError(LlmProviderError::retryable(
590 LlmProviderErrorKind::ServerOverloaded,
591 serde_json::json!({
592 "message": "provider overloaded"
593 }),
594 )),
595 "provider overloaded",
596 );
597
598 assert!(err.is_recoverable());
599 }
600
601 #[test]
602 fn provider_error_fails_closed_when_json_claims_retryable() {
603 let err = AgentError::llm(
604 "anthropic",
605 LlmFailureReason::ProviderError(LlmProviderError::non_retryable(
606 LlmProviderErrorKind::InvalidRequest,
607 serde_json::json!({
608 "kind": "server_overloaded",
609 "retryable": true,
610 "message": "json payload must not control retryability"
611 }),
612 )),
613 "invalid request",
614 );
615
616 assert!(!err.is_recoverable());
617 }
618
619 #[test]
622 fn test_is_rate_limited_true_for_rate_limit_error() {
623 let err = AgentError::llm(
624 "anthropic",
625 LlmFailureReason::RateLimited {
626 retry_after: Some(std::time::Duration::from_secs(30)),
627 },
628 "rate limited",
629 );
630 assert!(err.is_rate_limited());
631 }
632
633 #[test]
634 fn test_is_rate_limited_false_for_other_errors() {
635 let err = AgentError::llm(
636 "anthropic",
637 LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
638 "timeout",
639 );
640 assert!(!err.is_rate_limited());
641
642 let err = AgentError::llm("anthropic", LlmFailureReason::AuthError, "bad key");
643 assert!(!err.is_rate_limited());
644 }
645
646 #[test]
647 fn test_retry_after_hint_returns_duration_for_rate_limit() {
648 let err = AgentError::llm(
649 "anthropic",
650 LlmFailureReason::RateLimited {
651 retry_after: Some(std::time::Duration::from_secs(60)),
652 },
653 "rate limited",
654 );
655 assert_eq!(
656 err.retry_after_hint(),
657 Some(std::time::Duration::from_secs(60))
658 );
659 }
660
661 #[test]
662 fn test_retry_after_hint_returns_none_for_non_rate_limit() {
663 let err = AgentError::llm(
664 "anthropic",
665 LlmFailureReason::NetworkTimeout { duration_ms: 5000 },
666 "timeout",
667 );
668 assert_eq!(err.retry_after_hint(), None);
669 }
670
671 #[test]
672 fn test_timeout_variants_not_graceful() {
673 let err = AgentError::llm(
674 "anthropic",
675 LlmFailureReason::NetworkTimeout { duration_ms: 1000 },
676 "timeout",
677 );
678 assert!(!err.is_graceful());
679
680 let err = AgentError::llm(
681 "anthropic",
682 LlmFailureReason::CallTimeout { duration_ms: 1000 },
683 "timeout",
684 );
685 assert!(!err.is_graceful());
686 }
687
688 #[test]
691 fn test_build_error_variant_exists_and_carries_message() {
692 let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
693 match &err {
694 AgentError::BuildError(msg) => {
695 assert!(
696 msg.contains("API key"),
697 "message should contain source text"
698 );
699 }
700 other => panic!("expected BuildError, got: {other}"),
701 }
702 }
703
704 #[test]
705 fn test_build_error_is_not_recoverable() {
706 let err = AgentError::BuildError("Unknown provider for model 'llama-3'".to_string());
707 assert!(!err.is_recoverable(), "build errors are not recoverable");
708 }
709
710 #[test]
711 fn test_build_error_is_not_graceful() {
712 let err = AgentError::BuildError("Missing API key".to_string());
713 assert!(!err.is_graceful(), "build errors are not graceful");
714 }
715
716 #[test]
717 fn test_build_error_display() {
718 let err = AgentError::BuildError("Missing API key for provider 'anthropic'".to_string());
719 let display = err.to_string();
720 assert!(
721 display.contains("Build error")
722 || display.contains("build error")
723 || display.contains("Missing API key"),
724 "display should mention the build error: {display}"
725 );
726 }
727
728 #[test]
731 fn test_terminal_failure_carries_typed_outcome() {
732 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
733
734 let err = AgentError::TerminalFailure {
736 outcome: TurnTerminalOutcome::Failed,
737 cause_kind: TurnTerminalCauseKind::LlmFailure,
738 message: "llm failed".to_string(),
739 };
740 match &err {
741 AgentError::TerminalFailure {
742 outcome,
743 cause_kind,
744 ..
745 } => {
746 assert_eq!(*outcome, TurnTerminalOutcome::Failed);
748 assert_eq!(*cause_kind, TurnTerminalCauseKind::LlmFailure);
749 }
750 other => panic!("expected TerminalFailure, got: {other}"),
751 }
752 }
753
754 #[test]
755 fn test_terminal_failure_display_includes_outcome() {
756 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
757
758 let err = AgentError::TerminalFailure {
759 outcome: TurnTerminalOutcome::TimeBudgetExceeded,
760 cause_kind: TurnTerminalCauseKind::TimeBudgetExceeded,
761 message: "deadline reached".to_string(),
762 };
763 let display = err.to_string();
764 assert!(
765 display.contains("TimeBudgetExceeded"),
766 "display should include the outcome variant name: {display}"
767 );
768 assert!(
769 display.contains("TimeBudgetExceeded") && display.contains("deadline reached"),
770 "display should include cause and display message: {display}"
771 );
772 }
773
774 #[test]
777 fn tool_variant_preserves_access_denied_error_code() {
778 let err = AgentError::tool(ToolError::access_denied("secret_tool"));
782 match &err {
783 AgentError::Tool { error } => {
784 assert_eq!(error.error_code(), "access_denied");
785 }
786 other => panic!("expected AgentError::Tool, got: {other}"),
787 }
788 assert_eq!(err.tool_error_code(), Some("access_denied"));
789 }
790
791 #[test]
792 fn tool_variant_preserves_not_found_error_code() {
793 let err = AgentError::tool(ToolError::not_found("missing_tool"));
795 assert_eq!(err.tool_error_code(), Some("tool_not_found"));
796 assert_ne!(
797 err.tool_error_code(),
798 AgentError::tool(ToolError::access_denied("missing_tool")).tool_error_code(),
799 "not_found must stay distinct from access_denied"
800 );
801 }
802
803 #[test]
804 fn tool_variant_preserves_invalid_arguments_error_code() {
805 let err = AgentError::tool(ToolError::invalid_arguments(
808 "search",
809 "tool call arguments projection failed: bad json",
810 ));
811 match &err {
812 AgentError::Tool { error } => {
813 assert_eq!(error.error_code(), "invalid_arguments");
814 }
815 other => panic!("expected AgentError::Tool, got: {other}"),
816 }
817 assert_eq!(err.tool_error_code(), Some("invalid_arguments"));
818 }
819
820 #[test]
821 fn test_terminal_failure_all_hard_failure_outcomes() {
822 use crate::turn_execution_authority::{TurnTerminalCauseKind, TurnTerminalOutcome};
823
824 for outcome in [
826 TurnTerminalOutcome::Failed,
827 TurnTerminalOutcome::TimeBudgetExceeded,
828 ] {
829 let err = AgentError::TerminalFailure {
830 outcome,
831 cause_kind: TurnTerminalCauseKind::FatalFailure,
832 message: "terminal".to_string(),
833 };
834 assert!(
835 !err.is_graceful(),
836 "TerminalFailure({outcome:?}) should not be graceful"
837 );
838 }
839 }
840}