1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
//! Error types for the clawft framework.
//!
//! Provides [`ClawftError`] as the top-level error type and [`ChannelError`]
//! for channel-specific failures. Both are non-exhaustive to allow future
//! extension without breaking downstream.
use thiserror::Error;
/// Top-level error type for the clawft framework.
///
/// Variants are grouped into recoverable (retry, timeout, rate-limit) and
/// fatal (config, plugin, I/O) categories to guide callers on whether
/// retrying is worthwhile.
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ClawftError {
// ── Recoverable ──────────────────────────────────────────────────
/// A transient failure that may succeed on retry.
#[error("retry required: {source} (attempt {attempts})")]
Retry {
/// The underlying error.
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
/// How many attempts have been made so far.
attempts: u32,
},
/// An operation exceeded its deadline.
#[error("operation timed out: {operation}")]
Timeout {
/// Human-readable name of the operation that timed out.
operation: String,
},
/// A provider returned an error (e.g. bad request, server error).
#[error("provider error: {message}")]
Provider {
/// Provider-supplied error message.
message: String,
},
/// The provider is throttling requests.
#[error("rate limited: retry after {retry_after_ms}ms")]
RateLimited {
/// Suggested wait time in milliseconds before retrying.
retry_after_ms: u64,
},
// ── Fatal ────────────────────────────────────────────────────────
/// Configuration is malformed or semantically invalid.
#[error("invalid config: {reason}")]
ConfigInvalid {
/// What is wrong with the configuration.
reason: String,
},
/// A plugin/extension could not be loaded.
#[error("failed to load plugin: {plugin}")]
PluginLoadFailed {
/// Name or path of the plugin that failed.
plugin: String,
},
/// Underlying I/O error.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// JSON serialization / deserialization error.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// A channel-layer error bubbled up.
#[error("channel error: {0}")]
Channel(String),
/// A security boundary was violated (path traversal, oversized input, etc.)
#[error("security violation: {reason}")]
SecurityViolation {
/// What policy was violated.
reason: String,
},
/// The per-conversation cost circuit-breaker tripped (WEFT-322).
///
/// Returned by [`AgentLoop::handle_turn`] when accumulated usage on
/// `conv_id` would exceed `dimension`'s configured cap (`limit`).
/// `used` is the value that caused the trip. The conversation is
/// marked `circuit_open` in the budget store; subsequent calls
/// fail-fast with this same error until reset via
/// `agent.chat.reset_budget`.
#[error(
"conversation budget exceeded for `{conv_id}`: \
{dimension} {used} >= limit {limit} (circuit_open until reset_budget)"
)]
ConversationBudgetExceeded {
/// Conversation identifier whose budget tripped.
conv_id: String,
/// One of `"tokens"`, `"usd"`, or `"iterations"`.
dimension: String,
/// Configured cap that was reached.
limit: f64,
/// Accumulated value at the moment of the trip.
used: f64,
},
/// A `clawft-cow-memory` checkpoint/rollback/promote operation failed
/// (WEFT-616 Phase 2: per-turn COW memory checkpointing).
///
/// `clawft-types` does not depend on `clawft-cow-memory` (wrong
/// direction — `clawft-cow-memory` has no `clawft-*` deps at all), so
/// this wraps the underlying `CowMemoryError`'s message rather than
/// using `#[from]`. Constructed in `clawft-core`.
#[error("cow memory error: {reason}")]
CowMemory {
/// `Display` of the underlying `clawft_cow_memory::CowMemoryError`.
reason: String,
},
/// A review-mode proposal is pending (WEFT-654): the loop's cow lineage
/// holds an undecided turn — accept or discard it before dispatching
/// another. Constructed in `clawft-core::agent::loop_core`.
#[error("a pending proposal (`{label}`) awaits accept/discard before new turns can run")]
ProposalPending {
/// The held turn's bracket label (e.g. `turn:agent.chat:conv`).
label: String,
},
/// The turn was cancelled via a [`CancellationToken`] (WEFT-323).
///
/// Returned by `AgentLoop::handle_turn` / `run_tool_loop` when the
/// per-conversation cancel token is observed at an iteration boundary
/// (top of each tool-loop iteration). `AgentService::dispatch` maps
/// this to `AgentServiceError::Cancelled` so the wire response matches
/// the outer `select!` cancel path.
#[error("conversation `{conv_id}` was cancelled")]
Cancelled {
/// Conversation identifier whose cancel token was tripped.
conv_id: String,
},
}
/// Channel-specific error type.
///
/// Used by channel implementations (Telegram, Slack, Discord, etc.)
/// to report failures in connecting, authenticating, or exchanging messages.
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ChannelError {
/// Failed to establish a connection to the channel backend.
#[error("connection failed: {0}")]
ConnectionFailed(String),
/// Authentication / authorization was rejected.
#[error("authentication failed: {0}")]
AuthFailed(String),
/// Sending a message failed.
#[error("send failed: {0}")]
SendFailed(String),
/// Receiving a message failed.
#[error("receive failed: {0}")]
ReceiveFailed(String),
/// The channel is not currently connected.
#[error("not connected")]
NotConnected,
/// The requested channel was not found.
#[error("channel not found: {0}")]
NotFound(String),
/// Catch-all for errors that do not fit other variants.
#[error("{0}")]
Other(String),
}
/// A convenience alias used throughout the crate.
pub type Result<T> = std::result::Result<T, ClawftError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clawft_error_display() {
let err = ClawftError::Timeout {
operation: "llm_call".into(),
};
assert_eq!(err.to_string(), "operation timed out: llm_call");
}
#[test]
fn clawft_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let err: ClawftError = io_err.into();
assert!(matches!(err, ClawftError::Io(_)));
assert!(err.to_string().contains("missing"));
}
#[test]
fn clawft_error_from_json() {
let json_err = serde_json::from_str::<serde_json::Value>("{{bad}}").unwrap_err();
let err: ClawftError = json_err.into();
assert!(matches!(err, ClawftError::Json(_)));
}
#[test]
fn channel_error_display() {
let err = ChannelError::NotConnected;
assert_eq!(err.to_string(), "not connected");
let err = ChannelError::AuthFailed("bad token".into());
assert_eq!(err.to_string(), "authentication failed: bad token");
}
#[test]
fn retry_error_preserves_source() {
let source: Box<dyn std::error::Error + Send + Sync> = "transient".into();
let err = ClawftError::Retry {
source,
attempts: 3,
};
assert!(err.to_string().contains("attempt 3"));
assert!(err.to_string().contains("transient"));
}
#[test]
fn security_violation_display() {
let err = ClawftError::SecurityViolation {
reason: "path traversal detected".into(),
};
assert_eq!(
err.to_string(),
"security violation: path traversal detected"
);
}
#[test]
fn result_alias_works() {
fn ok_fn() -> Result<i32> {
Ok(42)
}
fn err_fn() -> Result<i32> {
Err(ClawftError::Provider {
message: "boom".into(),
})
}
assert_eq!(ok_fn().unwrap(), 42);
assert!(err_fn().is_err());
}
}