Skip to main content

codewhale_tools/
outcome.rs

1use crate::{ToolError, ToolResult};
2
3/// Machine-readable terminal state for one tool call.
4///
5/// This is intentionally separate from [`ToolResult::success`]: a cancelled
6/// call still needs a legacy model-visible result so the call/result transcript
7/// stays well formed, while the runtime must not report that call as a generic
8/// failure or infer cancellation from output text.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ToolTerminalStatus {
12    Succeeded,
13    Failed,
14    Denied,
15    InvalidArguments,
16    Cancelled,
17    TimedOut,
18}
19
20impl ToolTerminalStatus {
21    #[must_use]
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Succeeded => "succeeded",
25            Self::Failed => "failed",
26            Self::Denied => "denied",
27            Self::InvalidArguments => "invalid_arguments",
28            Self::Cancelled => "cancelled",
29            Self::TimedOut => "timed_out",
30        }
31    }
32}
33
34/// Terminal wrapper around the v0.9.1-compatible tool result/error contract.
35///
36/// Exactly one of `result` and `error` is populated by the constructors. The
37/// existing UI and transcript paths can keep consuming [`Self::legacy_result`]
38/// while audit and orchestration use [`Self::status`] directly.
39#[derive(Debug, Clone)]
40pub struct ToolExecutionOutcome {
41    pub status: ToolTerminalStatus,
42    result: Option<ToolResult>,
43    error: Option<ToolError>,
44}
45
46impl ToolExecutionOutcome {
47    #[must_use]
48    pub fn from_legacy(result: Result<ToolResult, ToolError>) -> Self {
49        match result {
50            Ok(result) => {
51                let status = if result.success {
52                    ToolTerminalStatus::Succeeded
53                } else {
54                    ToolTerminalStatus::Failed
55                };
56                Self {
57                    status,
58                    result: Some(result),
59                    error: None,
60                }
61            }
62            Err(error) => {
63                let status = match &error {
64                    ToolError::InvalidInput { .. } | ToolError::MissingField { .. } => {
65                        ToolTerminalStatus::InvalidArguments
66                    }
67                    ToolError::PathEscape { .. } | ToolError::PermissionDenied { .. } => {
68                        ToolTerminalStatus::Denied
69                    }
70                    ToolError::Timeout { .. } => ToolTerminalStatus::TimedOut,
71                    ToolError::Cancelled { .. } => ToolTerminalStatus::Cancelled,
72                    ToolError::ExecutionFailed { .. } | ToolError::NotAvailable { .. } => {
73                        ToolTerminalStatus::Failed
74                    }
75                };
76                Self {
77                    status,
78                    result: None,
79                    error: Some(error),
80                }
81            }
82        }
83    }
84
85    /// Construct a cancelled outcome while retaining the legacy benign result
86    /// used to close the provider tool-call pair.
87    #[must_use]
88    pub fn cancelled(result: ToolResult) -> Self {
89        Self {
90            status: ToolTerminalStatus::Cancelled,
91            result: Some(result),
92            error: None,
93        }
94    }
95
96    pub fn legacy_result(&self) -> Result<ToolResult, ToolError> {
97        match (&self.result, &self.error) {
98            (Some(result), None) => Ok(result.clone()),
99            (None, Some(error)) => Err(error.clone()),
100            _ => unreachable!("ToolExecutionOutcome must contain exactly one result or error"),
101        }
102    }
103
104    pub fn into_legacy_result(self) -> Result<ToolResult, ToolError> {
105        match (self.result, self.error) {
106            (Some(result), None) => Ok(result),
107            (None, Some(error)) => Err(error),
108            _ => unreachable!("ToolExecutionOutcome must contain exactly one result or error"),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use std::path::PathBuf;
116
117    use super::*;
118
119    #[test]
120    fn legacy_results_map_to_explicit_terminal_statuses() {
121        let cases = [
122            (Ok(ToolResult::success("ok")), ToolTerminalStatus::Succeeded),
123            (Ok(ToolResult::error("failed")), ToolTerminalStatus::Failed),
124            (
125                Err(ToolError::invalid_input("bad arguments")),
126                ToolTerminalStatus::InvalidArguments,
127            ),
128            (
129                Err(ToolError::missing_field("path")),
130                ToolTerminalStatus::InvalidArguments,
131            ),
132            (
133                Err(ToolError::path_escape(PathBuf::from("../secret"))),
134                ToolTerminalStatus::Denied,
135            ),
136            (
137                Err(ToolError::permission_denied("no")),
138                ToolTerminalStatus::Denied,
139            ),
140            (
141                Err(ToolError::Timeout { seconds: 5 }),
142                ToolTerminalStatus::TimedOut,
143            ),
144            (
145                Err(ToolError::cancelled("stop")),
146                ToolTerminalStatus::Cancelled,
147            ),
148            (
149                Err(ToolError::execution_failed("boom")),
150                ToolTerminalStatus::Failed,
151            ),
152            (
153                Err(ToolError::not_available("missing")),
154                ToolTerminalStatus::Failed,
155            ),
156        ];
157
158        for (legacy, expected) in cases {
159            let outcome = ToolExecutionOutcome::from_legacy(legacy);
160            assert_eq!(outcome.status, expected);
161            let populated =
162                usize::from(outcome.result.is_some()) + usize::from(outcome.error.is_some());
163            assert_eq!(populated, 1);
164        }
165    }
166
167    #[test]
168    fn cancelled_result_keeps_legacy_pair_but_not_failure_status() {
169        let legacy = ToolResult::error("not executed");
170        let outcome = ToolExecutionOutcome::cancelled(legacy.clone());
171
172        assert_eq!(outcome.status, ToolTerminalStatus::Cancelled);
173        let restored = outcome.legacy_result().expect("legacy result");
174        assert_eq!(restored.content, legacy.content);
175        assert!(!restored.success);
176    }
177}