Skip to main content

task_graph_mcp/
error.rs

1//! Structured error and warning types for tool responses.
2
3use serde::Serialize;
4use std::fmt;
5
6/// Error codes for programmatic error handling.
7#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
9pub enum ErrorCode {
10    // Validation errors (4xx-like)
11    MissingRequiredField,
12    InvalidFieldValue,
13    InvalidState,
14    InvalidPath,
15    InvalidPrefix,
16
17    // Not found errors
18    AgentNotFound,
19    TaskNotFound,
20    FileNotFound,
21    AttachmentNotFound,
22
23    // Conflict errors
24    AlreadyClaimed,
25    AlreadyExists,
26    DependencyCycle,
27    TagMismatch,
28    NotOwner,
29    DependencyNotSatisfied,
30    GatesNotSatisfied,
31
32    // Internal errors
33    DatabaseError,
34    InternalError,
35    UnknownTool,
36}
37
38/// Warning codes for non-fatal issues.
39#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
40#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
41pub enum WarningCode {
42    /// Referenced task does not exist (link skipped)
43    TaskNotFound,
44    /// Referenced dependency does not exist (link skipped)
45    DependencyNotFound,
46    /// Tag is not in the known tags list
47    UnknownTag,
48    /// Phase is not in the known phases list
49    UnknownPhase,
50    /// Duplicate operation (no-op)
51    Duplicate,
52    /// Deprecated feature or parameter
53    Deprecated,
54}
55
56/// A warning about a non-fatal issue in a tool operation.
57#[derive(Debug, Clone, Serialize)]
58pub struct ToolWarning {
59    pub code: WarningCode,
60    pub message: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub field: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub value: Option<String>,
65}
66
67impl ToolWarning {
68    pub fn new(code: WarningCode, message: impl Into<String>) -> Self {
69        Self {
70            code,
71            message: message.into(),
72            field: None,
73            value: None,
74        }
75    }
76
77    pub fn with_field(mut self, field: impl Into<String>) -> Self {
78        self.field = Some(field.into());
79        self
80    }
81
82    pub fn with_value(mut self, value: impl Into<String>) -> Self {
83        self.value = Some(value.into());
84        self
85    }
86
87    // Convenience constructors
88
89    pub fn task_not_found(task_id: &str) -> Self {
90        Self::new(
91            WarningCode::TaskNotFound,
92            format!("Task '{}' not found, skipped", task_id),
93        )
94        .with_value(task_id)
95    }
96
97    pub fn dependency_not_found(task_id: &str, field: &str) -> Self {
98        Self::new(
99            WarningCode::DependencyNotFound,
100            format!("Dependency target '{}' not found, link skipped", task_id),
101        )
102        .with_field(field)
103        .with_value(task_id)
104    }
105
106    pub fn unknown_tag(tag: &str) -> Self {
107        Self::new(
108            WarningCode::UnknownTag,
109            format!("Tag '{}' is not in known tags list", tag),
110        )
111        .with_value(tag)
112    }
113
114    pub fn unknown_phase(phase: &str) -> Self {
115        Self::new(
116            WarningCode::UnknownPhase,
117            format!("Phase '{}' is not in known phases list", phase),
118        )
119        .with_value(phase)
120    }
121
122    pub fn duplicate(what: &str) -> Self {
123        Self::new(WarningCode::Duplicate, format!("{} already exists", what))
124    }
125
126    pub fn deprecated(feature: &str, alternative: &str) -> Self {
127        Self::new(
128            WarningCode::Deprecated,
129            format!("'{}' is deprecated, use '{}' instead", feature, alternative),
130        )
131    }
132}
133
134impl fmt::Display for ToolWarning {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "{}", self.message)
137    }
138}
139
140/// Structured error for tool responses.
141#[derive(Debug, Serialize)]
142pub struct ToolError {
143    pub code: ErrorCode,
144    pub message: String,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub field: Option<String>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub details: Option<String>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub blocked_by: Option<Vec<String>>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub suggestion: Option<String>,
153}
154
155impl ToolError {
156    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
157        Self {
158            code,
159            message: message.into(),
160            field: None,
161            details: None,
162            blocked_by: None,
163            suggestion: None,
164        }
165    }
166
167    pub fn with_field(mut self, field: impl Into<String>) -> Self {
168        self.field = Some(field.into());
169        self
170    }
171
172    pub fn with_details(mut self, details: impl Into<String>) -> Self {
173        self.details = Some(details.into());
174        self
175    }
176
177    pub fn with_blocked_by(mut self, blocked_by: Vec<String>) -> Self {
178        self.blocked_by = Some(blocked_by);
179        self
180    }
181
182    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
183        self.suggestion = Some(suggestion.into());
184        self
185    }
186
187    // Convenience constructors
188
189    pub fn missing_field(field: &str) -> Self {
190        Self::new(
191            ErrorCode::MissingRequiredField,
192            format!("{} is required", field),
193        )
194        .with_field(field)
195    }
196
197    pub fn invalid_value(field: &str, reason: &str) -> Self {
198        Self::new(ErrorCode::InvalidFieldValue, reason).with_field(field)
199    }
200
201    pub fn agent_not_found(agent_id: &str) -> Self {
202        Self::new(
203            ErrorCode::AgentNotFound,
204            format!("Agent not found: {}", agent_id),
205        )
206    }
207
208    pub fn task_not_found(task_id: &str) -> Self {
209        Self::new(
210            ErrorCode::TaskNotFound,
211            format!("Task not found: {}", task_id),
212        )
213    }
214
215    pub fn already_claimed(task_id: &str, owner: &str) -> Self {
216        Self::new(
217            ErrorCode::AlreadyClaimed,
218            format!("Task {} already claimed by {}", task_id, owner),
219        )
220    }
221
222    pub fn not_owner(task_id: &str, agent_id: &str) -> Self {
223        Self::new(
224            ErrorCode::NotOwner,
225            format!("Agent {} does not own task {}", agent_id, task_id),
226        )
227    }
228
229    pub fn dependency_cycle(blocker: &str, blocked: &str) -> Self {
230        Self::new(
231            ErrorCode::DependencyCycle,
232            format!(
233                "Adding dependency {} -> {} would create a cycle",
234                blocker, blocked
235            ),
236        )
237    }
238
239    pub fn tag_mismatch(missing: &str) -> Self {
240        Self::new(
241            ErrorCode::TagMismatch,
242            format!("Agent missing required tag(s): {}", missing),
243        )
244    }
245
246    pub fn deps_not_satisfied(blockers: &[String]) -> Self {
247        Self::new(
248            ErrorCode::DependencyNotSatisfied,
249            format!(
250                "Task blocked by unsatisfied dependencies: {}",
251                blockers.join(", ")
252            ),
253        )
254        .with_blocked_by(blockers.to_vec())
255        .with_suggestion(
256            "Wait for blocking tasks to complete, or call thinking() regularly to maintain heartbeat"
257                .to_string(),
258        )
259    }
260
261    pub fn gates_not_satisfied(status: &str, gates: &[String]) -> Self {
262        Self::new(
263            ErrorCode::GatesNotSatisfied,
264            format!(
265                "Cannot exit '{}': unsatisfied gates: {}",
266                status,
267                gates.join(", ")
268            ),
269        )
270    }
271
272    pub fn invalid_path(path: &str, reason: &str) -> Self {
273        Self::new(
274            ErrorCode::InvalidPath,
275            format!("Invalid path '{}': {}", path, reason),
276        )
277    }
278
279    pub fn prefix_not_lowercase(prefix: &str) -> Self {
280        Self::new(
281            ErrorCode::InvalidPrefix,
282            format!("Path prefix '{}' must be lowercase", prefix),
283        )
284    }
285
286    pub fn unknown_prefix(prefix: &str) -> Self {
287        Self::new(
288            ErrorCode::InvalidPrefix,
289            format!("Unknown path prefix: {}", prefix),
290        )
291    }
292
293    pub fn sandbox_escape(path: &str, root: &str) -> Self {
294        Self::new(
295            ErrorCode::InvalidPath,
296            format!("Path '{}' escapes sandbox root '{}'", path, root),
297        )
298    }
299
300    pub fn database(err: impl fmt::Display) -> Self {
301        Self::new(ErrorCode::DatabaseError, err.to_string())
302    }
303
304    pub fn internal(err: impl fmt::Display) -> Self {
305        Self::new(ErrorCode::InternalError, err.to_string())
306    }
307
308    pub fn unknown_tool(name: &str) -> Self {
309        Self::new(ErrorCode::UnknownTool, format!("Unknown tool: {}", name))
310    }
311}
312
313impl fmt::Display for ToolError {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "{}", self.message)
316    }
317}
318
319impl std::error::Error for ToolError {}
320
321// Allow using ? with anyhow errors by converting them
322impl From<anyhow::Error> for ToolError {
323    fn from(err: anyhow::Error) -> Self {
324        // Try to downcast to ToolError first
325        match err.downcast::<ToolError>() {
326            Ok(tool_err) => tool_err,
327            Err(err) => ToolError::internal(err),
328        }
329    }
330}
331
332/// Result type for tool operations.
333pub type ToolResult<T> = std::result::Result<T, ToolError>;