| 1 | use 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")] |
| 11 | pub enum ToolTerminalStatus { |
| 12 | Succeeded, |
| 13 | Failed, |
| 14 | Denied, |
| 15 | InvalidArguments, |
| 16 | Cancelled, |
| 17 | TimedOut, |
| 18 | } |
| 19 | |
| 20 | impl 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)] |
| 40 | pub struct ToolExecutionOutcome { |
| 41 | pub status: ToolTerminalStatus, |
| 42 | result: Option<ToolResult>, |
| 43 | error: Option<ToolError>, |
| 44 | } |
| 45 | |
| 46 | impl 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)] |
| 114 | mod 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 | } |
| 178 |