tirea_contract/runtime/tool_call/
suspension.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::io::decision_translation;
5
6#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8pub struct Suspension {
9 #[serde(default)]
11 pub id: String,
12 #[serde(default)]
14 pub action: String,
15 #[serde(default, skip_serializing_if = "String::is_empty")]
17 pub message: String,
18 #[serde(default, skip_serializing_if = "Value::is_null")]
20 pub parameters: Value,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub response_schema: Option<Value>,
24}
25
26impl Suspension {
27 pub fn new(id: impl Into<String>, action: impl Into<String>) -> Self {
29 Self {
30 id: id.into(),
31 action: action.into(),
32 message: String::new(),
33 parameters: Value::Null,
34 response_schema: None,
35 }
36 }
37
38 pub fn with_message(mut self, message: impl Into<String>) -> Self {
40 self.message = message.into();
41 self
42 }
43
44 pub fn with_parameters(mut self, parameters: Value) -> Self {
46 self.parameters = parameters;
47 self
48 }
49
50 pub fn with_response_schema(mut self, schema: Value) -> Self {
52 self.response_schema = Some(schema);
53 self
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SuspensionResponse {
60 pub target_id: String,
62 pub result: Value,
64}
65
66impl SuspensionResponse {
67 fn deny_string_token(value: &str) -> bool {
68 decision_translation::is_denied_token(value)
69 }
70
71 fn object_deny_flag(obj: &serde_json::Map<String, Value>) -> bool {
72 [
73 "denied",
74 "reject",
75 "rejected",
76 "cancel",
77 "canceled",
78 "cancelled",
79 "abort",
80 "aborted",
81 ]
82 .iter()
83 .any(|key| obj.get(*key).and_then(Value::as_bool).unwrap_or(false))
84 || ["status", "decision", "action"].iter().any(|key| {
85 obj.get(*key)
86 .and_then(Value::as_str)
87 .map(decision_translation::is_denied_token)
88 .unwrap_or(false)
89 })
90 }
91
92 pub fn new(target_id: impl Into<String>, result: Value) -> Self {
94 Self {
95 target_id: target_id.into(),
96 result,
97 }
98 }
99
100 pub fn is_approved(result: &Value) -> bool {
102 match result {
103 Value::Bool(b) => *b,
104 Value::String(s) => {
105 let lower = s.to_lowercase();
106 matches!(
107 lower.as_str(),
108 "true" | "yes" | "approved" | "allow" | "confirm" | "ok" | "accept"
109 )
110 }
111 Value::Object(obj) => {
112 obj.get("approved")
113 .and_then(|v| v.as_bool())
114 .unwrap_or(false)
115 || obj
116 .get("allowed")
117 .and_then(|v| v.as_bool())
118 .unwrap_or(false)
119 }
120 _ => false,
121 }
122 }
123
124 pub fn is_denied(result: &Value) -> bool {
126 match result {
127 Value::Bool(b) => !*b,
128 Value::String(s) => {
129 let lower = s.trim().to_lowercase();
130 Self::deny_string_token(&lower)
131 }
132 Value::Object(obj) => {
133 obj.get("approved")
134 .and_then(|v| v.as_bool())
135 .map(|v| !v)
136 .unwrap_or(false)
137 || Self::object_deny_flag(obj)
138 }
139 _ => false,
140 }
141 }
142
143 pub fn approved(&self) -> bool {
145 Self::is_approved(&self.result)
146 }
147
148 pub fn denied(&self) -> bool {
150 Self::is_denied(&self.result)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::SuspensionResponse;
157 use serde_json::json;
158
159 #[test]
160 fn suspension_response_treats_cancel_variants_as_denied() {
161 let denied_cases = [
162 json!("cancelled"),
163 json!("canceled"),
164 json!({"status":"cancelled"}),
165 json!({"decision":"abort"}),
166 json!({"canceled": true}),
167 json!({"cancelled": true}),
168 ];
169 for case in denied_cases {
170 assert!(
171 SuspensionResponse::is_denied(&case),
172 "expected denied for case: {case}"
173 );
174 }
175 }
176}