返回 CodeWhale
outcome.rs
根目录 / crates / tools / src / outcome.rs
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
178 lines RUST