monoloop_loop/transaction/
process_tool.rs1use super::tool_handler::{
14 LinkedToolExecutionHandle, ToolExecutionCompletion, ToolExecutionControl, ToolHandler,
15 ToolKillHandle,
16};
17use monoloop_contracts::{
18 CanonicalToolOutput, ToolCall, ToolCallContext, ToolCompletion, ToolExecutionId,
19 ToolRuntimeError, ToolStartError,
20};
21use std::future::Future;
22use std::pin::Pin;
23use std::process::Stdio;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26use tokio::io::AsyncWriteExt;
27use tokio::process::{Child, Command};
28use tokio::sync::oneshot;
29
30#[derive(Clone, Debug)]
35pub enum ProcessToolCommand {
36 Program {
38 program: String,
40 args: Vec<String>,
42 },
43 SleepUntilKilled {
45 seconds: u64,
47 },
48}
49
50#[derive(Clone, Debug)]
52pub struct ProcessIsolatedToolHandler {
53 command: ProcessToolCommand,
54 pid_slot: Option<Arc<std::sync::atomic::AtomicU32>>,
56}
57
58impl ProcessIsolatedToolHandler {
59 pub fn new(command: ProcessToolCommand) -> Self {
61 Self {
62 command,
63 pid_slot: None,
64 }
65 }
66
67 pub fn sleep_until_killed(seconds: u64) -> Self {
69 Self::new(ProcessToolCommand::SleepUntilKilled { seconds })
70 }
71
72 pub fn with_pid_slot(mut self, slot: Arc<std::sync::atomic::AtomicU32>) -> Self {
74 self.pid_slot = Some(slot);
75 self
76 }
77}
78
79impl ToolHandler for ProcessIsolatedToolHandler {
80 fn start(
81 &self,
82 call: ToolCall,
83 context: ToolCallContext,
84 ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
85 let control = ToolExecutionControl::new();
86 let kill_deadline = context.deadline;
88 let mut child = match &self.command {
89 ProcessToolCommand::Program { program, args } => Command::new(program)
90 .args(args)
91 .stdin(Stdio::piped())
92 .stdout(Stdio::null())
93 .stderr(Stdio::null())
94 .spawn()
95 .map_err(|_| ToolStartError::Rejected("process spawn failed"))?,
96 ProcessToolCommand::SleepUntilKilled { seconds } => Command::new("sleep")
98 .arg(seconds.to_string())
99 .stdin(Stdio::null())
100 .stdout(Stdio::null())
101 .stderr(Stdio::null())
102 .spawn()
103 .map_err(|_| ToolStartError::Rejected("process spawn failed"))?,
104 };
105
106 if let Some(slot) = &self.pid_slot {
107 if let Some(id) = child.id() {
108 slot.store(id, std::sync::atomic::Ordering::SeqCst);
109 }
110 }
111
112 let stdin = if matches!(self.command, ProcessToolCommand::Program { .. }) {
114 child.stdin.take()
115 } else {
116 None
117 };
118 let payload = if stdin.is_some() {
119 serde_json::to_vec(&call.arguments).unwrap_or_default()
120 } else {
121 Vec::new()
122 };
123
124 let (tx, rx) = oneshot::channel();
125 let (kill, drive) =
127 ToolKillHandle::from_child_driven_with_stdin(child, stdin, payload, tx, kill_deadline);
128
129 Ok(LinkedToolExecutionHandle {
130 execution_id: ToolExecutionId::generate(),
131 control,
132 completion: ToolExecutionCompletion::new(rx),
133 kill: Some(kill),
134 drive: Some(drive),
135 })
136 }
137
138 fn supports_abort(&self) -> bool {
139 false
140 }
141
142 fn supports_isolated_kill(&self) -> bool {
143 true
144 }
145
146 fn os_process_isolated(&self) -> bool {
147 true
148 }
149}
150
151impl ToolKillHandle {
152 pub fn from_child_driven(
156 child: Child,
157 completion_tx: oneshot::Sender<ToolCompletion>,
158 wait_deadline: Instant,
159 ) -> (Self, Pin<Box<dyn Future<Output = ()> + Send>>) {
160 Self::from_child_driven_with_stdin(child, None, Vec::new(), completion_tx, wait_deadline)
161 }
162
163 pub fn from_child_driven_with_stdin(
167 child: Child,
168 stdin: Option<tokio::process::ChildStdin>,
169 payload: Vec<u8>,
170 completion_tx: oneshot::Sender<ToolCompletion>,
171 wait_deadline: Instant,
172 ) -> (Self, Pin<Box<dyn Future<Output = ()> + Send>>) {
173 let child_arc = Arc::new(Mutex::new(Some(child)));
174 let child_for_wait = Arc::clone(&child_arc);
175 let kill = Self::from_process(Arc::clone(&child_arc));
176 let kill_for_drive = kill.clone();
177 let drive = Box::pin(async move {
178 let mut fail_deadline = false;
181 if let Some(mut stdin) = stdin {
182 let remaining = wait_deadline.saturating_duration_since(Instant::now());
183 let write_ok = matches!(
184 tokio::time::timeout(remaining, stdin.write_all(&payload)).await,
185 Ok(Ok(()))
186 );
187 drop(stdin);
189 if !write_ok {
190 fail_deadline = true;
191 if let Some(c) = child_for_wait
192 .lock()
193 .unwrap_or_else(|e| e.into_inner())
194 .as_mut()
195 {
196 let _ = c.start_kill();
197 }
198 }
199 }
200
201 let mut killed_at: Option<Instant> = if fail_deadline {
204 Some(Instant::now())
205 } else {
206 None
207 };
208 let post_kill_grace = Duration::from_secs(2);
209 let status = loop {
210 if killed_at.is_none() && Instant::now() >= wait_deadline {
211 if let Some(c) = child_for_wait
212 .lock()
213 .unwrap_or_else(|e| e.into_inner())
214 .as_mut()
215 {
216 let _ = c.start_kill();
217 }
218 killed_at = Some(Instant::now());
219 }
220 let polled = {
221 let mut guard = child_for_wait.lock().unwrap_or_else(|e| e.into_inner());
222 match guard.as_mut() {
223 Some(c) => match c.try_wait() {
224 Ok(Some(s)) => {
225 let _ = guard.take();
226 Some(s)
227 }
228 Ok(None) => None,
229 Err(_) => break None,
230 },
231 None => break None,
232 }
233 };
234 if let Some(st) = polled {
235 break Some(st);
236 }
237 if killed_at.is_some_and(|t| Instant::now() >= t + post_kill_grace) {
238 break None;
239 }
240 tokio::time::sleep(Duration::from_millis(5)).await;
241 };
242
243 let observed = status.is_some()
245 || child_for_wait
246 .lock()
247 .unwrap_or_else(|e| e.into_inner())
248 .is_none();
249 if observed {
250 kill_for_drive.note_process_reaped();
251 }
252
253 let completion = if fail_deadline {
254 ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded)
255 } else {
256 match status {
257 Some(st) if st.success() => ToolCompletion::Succeeded(
258 CanonicalToolOutput::Json(serde_json::json!({"ok": true})),
259 ),
260 Some(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed),
261 None => ToolCompletion::RuntimeFailed(ToolRuntimeError::CompletionLost),
262 }
263 };
264 let _ = completion_tx.send(completion);
265 });
266 (kill, drive)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::transaction::host_tools::RegisteredTool;
274 use crate::transaction::tool_handler::IsolatedKillableToolHandler;
275 use monoloop_contracts::{
276 ChannelId, JsonSchema, SessionId, SessionKey, ToolActionId, ToolCall, ToolCallContext,
277 ToolExecutionClass, ToolId, ToolLimits, ToolName, ToolOutputContract, ToolSpec,
278 ToolSuccessContract, TransactionId,
279 };
280 use std::sync::Arc;
281
282 fn ctx() -> ToolCallContext {
283 ToolCallContext {
284 transaction_id: TransactionId::generate(),
285 session_key: SessionKey::new(
286 ChannelId::try_new("c").unwrap(),
287 SessionId::try_new("s").unwrap(),
288 ),
289 exchange_id: Some(monoloop_contracts::ExchangeId::generate()),
290 tool_action_id: ToolActionId::new("a"),
291 tool_id: ToolId::try_new("p").unwrap(),
292 deadline: Instant::now() + Duration::from_secs(5),
293 }
294 }
295
296 fn call() -> ToolCall {
297 ToolCall {
298 tool_name: ToolName::try_new("p").unwrap(),
299 tool_id: ToolId::try_new("p").unwrap(),
300 provider_tool_call_id: "p".into(),
301 arguments: serde_json::json!({}),
302 request_ordinal: 0,
303 }
304 }
305
306 fn process_spec() -> ToolSpec {
307 let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
308 ToolSpec::try_new(
309 ToolId::try_new("p").unwrap(),
310 ToolName::try_new("p").unwrap(),
311 "process tool",
312 schema.clone(),
313 ToolOutputContract {
314 success: ToolSuccessContract::json(schema),
315 error_data_schema: None,
316 },
317 ToolLimits::default(),
318 ToolExecutionClass::ProcessIsolated {
319 grace: Duration::from_millis(50),
320 kill_deadline: Duration::from_secs(2),
321 },
322 )
323 .unwrap()
324 }
325
326 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
327 async fn process_isolated_owned_processes_counter_tracks_live_child() {
328 use std::sync::atomic::{AtomicU32, Ordering};
329 let counter = Arc::new(AtomicU32::new(0));
330 let handler = ProcessIsolatedToolHandler::sleep_until_killed(3600);
331 let mut handle = handler.start(call(), ctx()).expect("start");
332 let kill = handle.kill.as_ref().expect("kill");
333 kill.register_owned_process(Arc::clone(&counter));
334 assert_eq!(counter.load(Ordering::SeqCst), 1, "live child must count");
335 let drive = handle.drive.take().unwrap();
336 let wait = handle.completion.wait();
337 tokio::pin!(drive);
338 tokio::pin!(wait);
339 kill.kill();
340 tokio::time::timeout(Duration::from_secs(2), async {
341 tokio::select! {
342 _ = &mut wait => {}
343 _ = &mut drive => { let _ = wait.await; }
344 }
345 })
346 .await
347 .expect("reaped");
348 assert_eq!(
349 counter.load(Ordering::SeqCst),
350 0,
351 "reaped child must release owned_processes"
352 );
353 }
354
355 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
356 async fn process_isolated_kill_stops_sleeping_child() {
357 let handler = ProcessIsolatedToolHandler::sleep_until_killed(3600);
358 let mut handle = handler.start(call(), ctx()).expect("start");
359 assert!(handle.kill.as_ref().unwrap().is_process_isolated());
360 assert!(
361 handle.drive.is_some(),
362 "ProcessIsolated wait must be an inline drive (no spawn_blocking)"
363 );
364 let kill = handle.kill.expect("process kill handle");
365 handle.control.cancel();
366 let drive = handle.drive.take().unwrap();
368 let wait = handle.completion.wait();
369 tokio::pin!(drive);
370 tokio::pin!(wait);
371 tokio::time::sleep(Duration::from_millis(20)).await;
372 kill.kill();
373 tokio::time::timeout(Duration::from_secs(2), async {
374 tokio::select! {
375 _ = &mut wait => {}
376 _ = &mut drive => { let _ = wait.await; }
377 }
378 })
379 .await
380 .expect("child joined after kill");
381 kill.join_timeout(Duration::from_secs(1))
382 .await
383 .expect("process reaped");
384 }
385
386 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
387 async fn process_isolated_claims_structural_factory() {
388 let handler = ProcessIsolatedToolHandler::sleep_until_killed(1);
389 assert!(handler.os_process_isolated());
390 assert!(handler.supports_isolated_kill());
391 assert!(!handler.supports_abort());
392 }
393
394 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
395 async fn process_isolated_program_owns_before_stdin_and_is_killable() {
396 let handler = ProcessIsolatedToolHandler::new(ProcessToolCommand::Program {
398 program: "sleep".into(),
399 args: vec!["30".into()],
400 });
401 let mut big = call();
402 big.arguments = serde_json::json!({"pad": "x".repeat(64 * 1024)});
403 let mut handle = handler
404 .start(big, ctx())
405 .expect("start must return before stdin completes");
406 assert!(handle.kill.as_ref().unwrap().is_process_isolated());
407 let kill = handle.kill.clone().expect("kill");
408 let drive = handle.drive.take().unwrap();
409 kill.kill();
411 let _ = tokio::time::timeout(Duration::from_secs(2), drive).await;
412 kill.join_timeout(Duration::from_secs(1))
413 .await
414 .expect("child reaped after kill");
415 }
416
417 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
420 async fn process_isolated_stdin_timeout_reaps_only_after_observed_exit() {
421 use std::sync::atomic::{AtomicU32, Ordering};
422 let counter = Arc::new(AtomicU32::new(0));
423 let handler = ProcessIsolatedToolHandler::new(ProcessToolCommand::Program {
426 program: "sleep".into(),
427 args: vec!["30".into()],
428 });
429 let mut short = ctx();
430 short.deadline = Instant::now() + Duration::from_millis(80);
431 let mut big = call();
432 big.arguments = serde_json::json!({"pad": "x".repeat(256 * 1024)});
433 let mut handle = handler.start(big, short).expect("start");
434 let kill = handle.kill.as_ref().expect("kill");
435 kill.register_owned_process(Arc::clone(&counter));
436 assert_eq!(counter.load(Ordering::SeqCst), 1);
437 let drive = handle.drive.take().unwrap();
438 let wait = handle.completion.wait();
439 tokio::pin!(drive);
440 tokio::pin!(wait);
441 tokio::time::timeout(Duration::from_secs(3), async {
442 tokio::select! {
443 _ = &mut wait => {}
444 _ = &mut drive => { let _ = wait.await; }
445 }
446 })
447 .await
448 .expect("drive must conclude");
449 assert_eq!(
451 counter.load(Ordering::SeqCst),
452 0,
453 "note_process_reaped only after observed exit"
454 );
455 assert!(
456 !kill.has_join(),
457 "kill handle must report reaped after observed exit"
458 );
459 }
460
461 #[test]
462 fn process_isolated_rejects_dyn_handler_path() {
463 let spec = process_spec();
464 let tokio_handler = Arc::new(IsolatedKillableToolHandler::new(|_c, _x| {
465 Box::pin(async {
466 ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
467 })
468 })) as Arc<dyn ToolHandler>;
469 let err = RegisteredTool::try_new(spec, tokio_handler).unwrap_err();
470 let msg = format!("{err}");
471 assert!(
472 msg.contains("try_new_process_isolated") || msg.contains("ProcessIsolated"),
473 "got {msg}"
474 );
475 }
476
477 #[test]
478 fn process_isolated_accepts_structural_handler() {
479 let spec = process_spec();
480 RegisteredTool::try_new_process_isolated(
481 spec,
482 ProcessIsolatedToolHandler::sleep_until_killed(1),
483 )
484 .expect("structural ProcessIsolated ok");
485 }
486
487 #[test]
488 fn process_isolated_typed_api_rejects_wrong_class() {
489 let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
490 let spec = ToolSpec::try_new(
491 ToolId::try_new("p").unwrap(),
492 ToolName::try_new("p").unwrap(),
493 "abortable",
494 schema.clone(),
495 ToolOutputContract {
496 success: ToolSuccessContract::json(schema),
497 error_data_schema: None,
498 },
499 ToolLimits::default(),
500 ToolExecutionClass::AbortableAtYield {
501 grace: Duration::from_secs(1),
502 },
503 )
504 .unwrap();
505 let err = RegisteredTool::try_new_process_isolated(
506 spec,
507 ProcessIsolatedToolHandler::sleep_until_killed(1),
508 )
509 .unwrap_err();
510 assert!(format!("{err}").contains("ProcessIsolated"));
511 }
512}