Skip to main content

nenjo_tool_api/
async_ops.rs

1//! Shared contracts for model-visible async operation tools.
2//!
3//! Runtime crates own operation scheduling, cancellation, polling, and event
4//! delivery. This module only defines stable tool names, argument DTOs, JSON
5//! schemas, and operation lifecycle enums.
6
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::json;
9
10pub const WAIT_OPERATIONS_TOOL_NAME: &str = "wait_operations";
11pub const INSPECT_OPERATIONS_TOOL_NAME: &str = "inspect_operations";
12pub const STOP_OPERATIONS_TOOL_NAME: &str = "stop_operations";
13pub const SEND_OPERATION_INPUT_TOOL_NAME: &str = "send_operation_input";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AsyncOperationKind {
18    Ability,
19    Delegation,
20    SubAgent,
21    Shell,
22    Media,
23}
24
25impl AsyncOperationKind {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Ability => "ability",
29            Self::Delegation => "delegation",
30            Self::SubAgent => "sub_agent",
31            Self::Shell => "shell",
32            Self::Media => "media",
33        }
34    }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum AsyncOperationStatus {
40    Running,
41    WaitingForInput,
42    Completed,
43    Failed,
44    Stopped,
45}
46
47impl AsyncOperationStatus {
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Running => "running",
51            Self::WaitingForInput => "waiting_for_input",
52            Self::Completed => "completed",
53            Self::Failed => "failed",
54            Self::Stopped => "stopped",
55        }
56    }
57
58    pub fn can_receive_input(self) -> bool {
59        matches!(self, Self::Running | Self::WaitingForInput)
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum AsyncOperationSignalKind {
66    Started,
67    Progress,
68    NeedsInput,
69    Completed,
70    Failed,
71    Stopped,
72}
73
74impl AsyncOperationSignalKind {
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Self::Started => "started",
78            Self::Progress => "progress",
79            Self::NeedsInput => "needs_input",
80            Self::Completed => "completed",
81            Self::Failed => "failed",
82            Self::Stopped => "stopped",
83        }
84    }
85}
86
87#[derive(Debug, Clone, Deserialize)]
88pub struct InspectOperationsArgs {
89    #[serde(default)]
90    pub operations: Vec<String>,
91    #[serde(default)]
92    pub kind: Option<AsyncOperationKind>,
93    #[serde(default)]
94    pub include_transcript: bool,
95    #[serde(
96        default = "default_inspect_limit",
97        deserialize_with = "deserialize_usize_from_json_number"
98    )]
99    pub limit: usize,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103pub struct StopOperationsArgs {
104    #[serde(default)]
105    pub operations: Vec<String>,
106    #[serde(default)]
107    pub kind: Option<AsyncOperationKind>,
108    pub reason: Option<String>,
109}
110
111#[derive(Debug, Clone, Deserialize)]
112pub struct WaitOperationsArgs {
113    #[serde(
114        default = "default_wait_seconds",
115        deserialize_with = "deserialize_u64_from_json_number"
116    )]
117    pub seconds: u64,
118    #[serde(default)]
119    pub kind: Option<AsyncOperationKind>,
120    pub reason: Option<String>,
121}
122
123#[derive(Debug, Clone, Deserialize)]
124pub struct SendOperationInputArgs {
125    #[serde(default)]
126    pub operations: Vec<String>,
127    pub message: String,
128}
129
130pub fn inspect_operations_parameters_schema() -> serde_json::Value {
131    json!({
132        "type": "object",
133        "properties": {
134            "operations": {"type": "array", "items": {"type": "string"}},
135            "kind": operation_kind_schema(),
136            "include_transcript": {"type": "boolean"},
137            "limit": {"type": "integer", "minimum": 1, "maximum": 50}
138        },
139        "additionalProperties": false
140    })
141}
142
143pub fn stop_operations_parameters_schema() -> serde_json::Value {
144    json!({
145        "type": "object",
146        "properties": {
147            "operations": {"type": "array", "items": {"type": "string"}},
148            "kind": operation_kind_schema(),
149            "reason": {"type": "string"}
150        },
151        "additionalProperties": false
152    })
153}
154
155pub fn wait_operations_parameters_schema() -> serde_json::Value {
156    json!({
157        "type": "object",
158        "properties": {
159            "seconds": {"type": "integer", "minimum": 1, "maximum": 30},
160            "kind": operation_kind_schema(),
161            "reason": {"type": "string"}
162        },
163        "additionalProperties": false
164    })
165}
166
167pub fn send_operation_input_parameters_schema() -> serde_json::Value {
168    json!({
169        "type": "object",
170        "properties": {
171            "operations": {"type": "array", "items": {"type": "string"}},
172            "message": {"type": "string"}
173        },
174        "required": ["operations", "message"],
175        "additionalProperties": false
176    })
177}
178
179pub fn operation_kind_schema() -> serde_json::Value {
180    json!({
181        "type": "string",
182        "enum": ["ability", "delegation", "sub_agent", "shell", "media"]
183    })
184}
185
186fn default_inspect_limit() -> usize {
187    30
188}
189
190pub fn deserialize_usize_from_json_number<'de, D>(deserializer: D) -> Result<usize, D::Error>
191where
192    D: Deserializer<'de>,
193{
194    let value = serde_json::Value::deserialize(deserializer)?;
195    match value {
196        serde_json::Value::Number(number) => {
197            if let Some(raw) = number.as_u64() {
198                usize::try_from(raw).map_err(serde::de::Error::custom)
199            } else if let Some(raw) = number.as_f64() {
200                if raw.is_finite() && raw.fract() == 0.0 && raw >= 0.0 {
201                    usize::try_from(raw as u64).map_err(serde::de::Error::custom)
202                } else {
203                    Err(serde::de::Error::custom(
204                        "expected a non-negative whole number",
205                    ))
206                }
207            } else {
208                Err(serde::de::Error::custom(
209                    "expected a non-negative whole number",
210                ))
211            }
212        }
213        other => Err(serde::de::Error::custom(format!(
214            "expected a non-negative whole number, got {other}"
215        ))),
216    }
217}
218
219pub fn deserialize_u64_from_json_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
220where
221    D: Deserializer<'de>,
222{
223    let value = serde_json::Value::deserialize(deserializer)?;
224    match value {
225        serde_json::Value::Number(number) => {
226            if let Some(raw) = number.as_u64() {
227                Ok(raw)
228            } else if let Some(raw) = number.as_f64() {
229                if raw.is_finite() && raw.fract() == 0.0 && raw >= 0.0 {
230                    Ok(raw as u64)
231                } else {
232                    Err(serde::de::Error::custom(
233                        "expected a non-negative whole number",
234                    ))
235                }
236            } else {
237                Err(serde::de::Error::custom(
238                    "expected a non-negative whole number",
239                ))
240            }
241        }
242        other => Err(serde::de::Error::custom(format!(
243            "expected a non-negative whole number, got {other}"
244        ))),
245    }
246}
247
248fn default_wait_seconds() -> u64 {
249    10
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn async_operation_kind_uses_wire_names() {
258        assert_eq!(AsyncOperationKind::Ability.as_str(), "ability");
259        assert_eq!(AsyncOperationKind::Delegation.as_str(), "delegation");
260        assert_eq!(AsyncOperationKind::SubAgent.as_str(), "sub_agent");
261        assert_eq!(AsyncOperationKind::Shell.as_str(), "shell");
262        assert_eq!(AsyncOperationKind::Media.as_str(), "media");
263    }
264
265    #[test]
266    fn wait_args_deserialize_with_defaults() {
267        let args: WaitOperationsArgs = serde_json::from_value(json!({})).unwrap();
268
269        assert_eq!(args.seconds, 10);
270        assert_eq!(args.kind, None);
271    }
272
273    #[test]
274    fn wait_args_accept_whole_float_seconds_from_model_args() {
275        let args: WaitOperationsArgs = serde_json::from_value(json!({
276            "kind": "ability",
277            "seconds": 30.0
278        }))
279        .unwrap();
280
281        assert_eq!(args.seconds, 30);
282        assert_eq!(args.kind, Some(AsyncOperationKind::Ability));
283    }
284
285    #[test]
286    fn wait_args_reject_fractional_seconds() {
287        let err = serde_json::from_value::<WaitOperationsArgs>(json!({
288            "seconds": 5.5
289        }))
290        .unwrap_err();
291
292        assert!(err.to_string().contains("whole number"));
293    }
294
295    #[test]
296    fn inspect_args_accept_whole_float_limit_from_model_args() {
297        let args: InspectOperationsArgs = serde_json::from_value(json!({
298            "operations": ["ability_build_agent_2"],
299            "include_transcript": true,
300            "limit": 5.0
301        }))
302        .unwrap();
303
304        assert_eq!(args.limit, 5);
305    }
306
307    #[test]
308    fn inspect_args_reject_fractional_limit() {
309        let err = serde_json::from_value::<InspectOperationsArgs>(json!({
310            "limit": 5.5
311        }))
312        .unwrap_err();
313
314        assert!(err.to_string().contains("whole number"));
315    }
316}