1use std::collections::BTreeMap;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde_json::Value;
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9use tokio::sync::Mutex;
10use tokio::time::timeout;
11use tracing::Instrument;
12
13use crate::events::{decode_stderr_event, AgentEvent};
14use crate::model::ModelClient;
15use crate::process::{isolate_process_group, terminate_child_tree};
16use crate::store;
17use crate::tools::{require_str, require_string_array, ToolResult, ToolRuntime};
18use crate::types::ToolDefinition;
19
20pub const DEFAULT_THREAD_TIMEOUT_SECS: u64 = 60 * 60;
21pub const MIN_THREAD_TIMEOUT_SECS: u64 = 30 * 60;
22
23pub fn dispatch_definition() -> ToolDefinition {
24 use serde_json::json;
25 def(
26 "thread",
27 "Dispatch a named worker thread. The worker reuses its own retained history and can pull the latest retained episode from other named threads. Default timeout is configured by sac; built-in default is 3600 seconds and minimum timeout is 1800 seconds.",
28 json!({
29 "type": "object",
30 "properties": {
31 "name": { "type": "string", "description": "Thread name. Creates if new, reuses if existing." },
32 "action": { "type": "string", "description": "Task for the worker." },
33 "threads": {
34 "type": "array",
35 "items": { "type": "string" },
36 "description": "Other thread names whose latest retained episodes should be loaded."
37 },
38 "timeout": { "type": "integer", "description": "Timeout in seconds for this dispatch (default 3600, minimum 1800)." }
39 },
40 "required": ["name", "action"]
41 }),
42 )
43}
44
45pub fn threads_definition() -> ToolDefinition {
46 use serde_json::json;
47 def(
48 "threads",
49 "List retained thread lanes in the current orchestrator session. This reports persisted thread history, not whether a worker process is actively running right now.",
50 json!({
51 "type": "object",
52 "properties": {}
53 }),
54 )
55}
56
57pub fn thread_read_definition() -> ToolDefinition {
58 use serde_json::json;
59 def(
60 "thread_read",
61 "Read the full retained episode history for one thread.",
62 json!({
63 "type": "object",
64 "properties": {
65 "name": { "type": "string", "description": "Thread name." }
66 },
67 "required": ["name"]
68 }),
69 )
70}
71
72pub fn thread_delete_definition() -> ToolDefinition {
73 use serde_json::json;
74 def(
75 "thread_delete",
76 "Delete one thread and all its retained episodes.",
77 json!({
78 "type": "object",
79 "properties": {
80 "name": { "type": "string", "description": "Thread name." }
81 },
82 "required": ["name"]
83 }),
84 )
85}
86
87pub async fn execute_dispatch(
88 args: Value,
89 runtime: &ToolRuntime,
90 client: &ModelClient,
91) -> ToolResult {
92 let thread_name = match require_str(&args, "name") {
93 Ok(s) => s,
94 Err(e) => return e,
95 };
96 let action = match require_str(&args, "action") {
97 Ok(s) => s,
98 Err(e) => return e,
99 };
100 let source_threads = match require_string_array(&args, "threads") {
101 Ok(v) => v,
102 Err(e) => return e,
103 };
104 let session_id = match require_session(runtime) {
105 Ok(s) => s.to_string(),
106 Err(e) => return e,
107 };
108 let timeout_secs = resolve_thread_timeout_secs(&args, runtime.thread_timeout_secs);
109
110 async {
111 if !mark_thread_active(runtime, &thread_name).await {
112 tracing::warn!(thread_name = %thread_name, "thread dispatch rejected because thread is already active");
113 return ToolResult {
114 content: format!(
115 "Thread '{}' is already running; retry after the current dispatch completes.",
116 thread_name
117 ),
118 is_error: true,
119 };
120 }
121
122 tracing::info!(
123 session_id = %session_id,
124 thread_name = %thread_name,
125 action_len = action.len(),
126 source_threads = ?source_threads,
127 timeout_secs,
128 backend = ?client.backend(),
129 model = %client.model,
130 base_url = %client.base_url(),
131 "dispatching managed worker thread"
132 );
133
134 runtime.event_sink.emit(AgentEvent::ThreadStarted {
135 name: thread_name.clone(),
136 action: action.clone(),
137 source_threads: source_threads.clone(),
138 });
139
140 let result = run_worker(
141 runtime,
142 client,
143 &session_id,
144 &thread_name,
145 &action,
146 &source_threads,
147 timeout_secs,
148 )
149 .await;
150 unmark_thread_active(runtime, &thread_name).await;
151
152 match result {
153 Err(e) => {
154 tracing::error!(thread_name = %thread_name, error = %e, "failed to spawn managed worker thread");
155 runtime.event_sink.emit(AgentEvent::Error {
156 thread_name: Some(thread_name.clone()),
157 message: format!("Failed to spawn thread '{}': {}", thread_name, e),
158 });
159 ToolResult {
160 content: format!("Failed to spawn thread '{}': {}", thread_name, e),
161 is_error: true,
162 }
163 }
164 Ok(run) if run.timed_out => {
165 tracing::warn!(
166 thread_name = %thread_name,
167 exit_code = run.exit_code,
168 stderr_len = run.stderr.len(),
169 stdout_len = run.stdout.len(),
170 timeout_reason = ?run.timeout_reason,
171 timeout_secs,
172 "managed worker thread timed out"
173 );
174 let timeout_reason = run.timeout_reason.clone();
175 runtime.event_sink.emit(AgentEvent::ThreadFinished {
176 name: thread_name.clone(),
177 exit_code: run.exit_code,
178 timed_out: true,
179 timeout_reason: timeout_reason.clone(),
180 });
181 ToolResult {
182 content: match timeout_reason {
183 Some(reason) => {
184 format!(
185 "Thread '{}' timed out after {}s.\n{}",
186 thread_name, timeout_secs, reason
187 )
188 }
189 None => format!("Thread '{}' timed out after {}s", thread_name, timeout_secs),
190 },
191 is_error: true,
192 }
193 }
194 Ok(run) if run.exit_code != 0 => {
195 tracing::error!(
196 thread_name = %thread_name,
197 exit_code = run.exit_code,
198 stderr_len = run.stderr.len(),
199 stdout_len = run.stdout.len(),
200 "managed worker thread exited with failure"
201 );
202 runtime.event_sink.emit(AgentEvent::ThreadFinished {
203 name: thread_name.clone(),
204 exit_code: run.exit_code,
205 timed_out: false,
206 timeout_reason: None,
207 });
208 let details = if !run.stderr.trim().is_empty() {
209 run.stderr.trim().to_string()
210 } else if !run.stdout.trim().is_empty() {
211 run.stdout.trim().to_string()
212 } else {
213 "no output".to_string()
214 };
215 ToolResult {
216 content: format!(
217 "Thread '{}' failed (exit {}):\n{}",
218 thread_name, run.exit_code, details
219 ),
220 is_error: true,
221 }
222 }
223 Ok(run) => {
224 tracing::info!(
225 thread_name = %thread_name,
226 exit_code = run.exit_code,
227 stderr_len = run.stderr.len(),
228 stdout_len = run.stdout.len(),
229 "managed worker thread completed successfully"
230 );
231 runtime.event_sink.emit(AgentEvent::ThreadFinished {
232 name: thread_name.clone(),
233 exit_code: run.exit_code,
234 timed_out: false,
235 timeout_reason: None,
236 });
237 ToolResult {
238 content: run.stdout.trim().to_string(),
239 is_error: false,
240 }
241 }
242 }
243 }
244 .instrument(tracing::info_span!(
245 "thread_dispatch",
246 session_id = %session_id,
247 thread_name = %thread_name,
248 source_thread_count = source_threads.len(),
249 timeout_secs,
250 store_path = %runtime.store_path.display(),
251 sandboxed = runtime.sandbox.is_some(),
252 ))
253 .await
254}
255
256pub async fn execute_threads(runtime: &ToolRuntime) -> ToolResult {
257 let session_id = match require_session(runtime) {
258 Ok(s) => s.to_string(),
259 Err(e) => return e,
260 };
261
262 let store_path = runtime.store_path.clone();
263 let sid = session_id.clone();
264 let threads =
265 match tokio::task::spawn_blocking(move || store::list_threads(&store_path, &sid)).await {
266 Ok(Ok(threads)) => threads,
267 Ok(Err(error)) => {
268 return ToolResult {
269 content: format!("Error listing threads: {}", error),
270 is_error: true,
271 }
272 }
273 Err(join_error) => {
274 return ToolResult {
275 content: format!("Internal error listing threads: {}", join_error),
276 is_error: true,
277 }
278 }
279 };
280
281 if threads.is_empty() {
282 return ToolResult {
283 content: "No retained threads in this session.".to_string(),
284 is_error: false,
285 };
286 }
287
288 let active_threads = runtime.active_threads.lock().await.clone();
289
290 let mut output = String::from("Retained threads:");
291 for thread in threads {
292 output.push_str(&format!(
293 "\n- {} | {} episodes | status: {} | created {} | updated {}",
294 thread.name,
295 thread.episode_count,
296 if active_threads.contains(&thread.name) {
297 "running"
298 } else {
299 "retained"
300 },
301 thread.created_at,
302 thread.updated_at
303 ));
304 if let Some(action) = thread.latest_action.as_deref() {
305 output.push_str(&format!(" | last action: {}", action));
306 }
307 }
308
309 ToolResult {
310 content: output,
311 is_error: false,
312 }
313}
314
315pub async fn execute_thread_read(args: Value, runtime: &ToolRuntime) -> ToolResult {
316 let thread_name = match require_str(&args, "name") {
317 Ok(s) => s,
318 Err(e) => return e,
319 };
320 let session_id = match require_session(runtime) {
321 Ok(s) => s.to_string(),
322 Err(e) => return e,
323 };
324
325 let store_path = runtime.store_path.clone();
326 let sid = session_id.clone();
327 let tname = thread_name.clone();
328 match tokio::task::spawn_blocking(move || store::thread_read(&store_path, &sid, &tname)).await {
329 Ok(Ok(episodes)) => ToolResult {
330 content: store::render_thread_document(&thread_name, &episodes),
331 is_error: false,
332 },
333 Ok(Err(error)) => ToolResult {
334 content: format!("Error reading thread '{}': {}", thread_name, error),
335 is_error: true,
336 },
337 Err(join_error) => ToolResult {
338 content: format!(
339 "Internal error reading thread '{}': {}",
340 thread_name, join_error
341 ),
342 is_error: true,
343 },
344 }
345}
346
347pub async fn execute_thread_delete(args: Value, runtime: &ToolRuntime) -> ToolResult {
348 let thread_name = match require_str(&args, "name") {
349 Ok(s) => s,
350 Err(e) => return e,
351 };
352 let session_id = match require_session(runtime) {
353 Ok(s) => s.to_string(),
354 Err(e) => return e,
355 };
356
357 if is_thread_active(runtime, &thread_name).await {
358 return ToolResult {
359 content: format!(
360 "Thread '{}' is currently running; wait for it to finish before deleting it.",
361 thread_name
362 ),
363 is_error: true,
364 };
365 }
366
367 let store_path = runtime.store_path.clone();
368 let sid = session_id.clone();
369 let tname = thread_name.clone();
370 match tokio::task::spawn_blocking(move || store::delete_thread(&store_path, &sid, &tname)).await
371 {
372 Ok(Ok(true)) => ToolResult {
373 content: format!(
374 "Deleted thread '{}' and its retained episodes.",
375 thread_name
376 ),
377 is_error: false,
378 },
379 Ok(Ok(false)) => ToolResult {
380 content: format!("Thread '{}' does not exist in this session.", thread_name),
381 is_error: true,
382 },
383 Ok(Err(error)) => ToolResult {
384 content: format!("Error deleting thread '{}': {}", thread_name, error),
385 is_error: true,
386 },
387 Err(join_error) => ToolResult {
388 content: format!(
389 "Internal error deleting thread '{}': {}",
390 thread_name, join_error
391 ),
392 is_error: true,
393 },
394 }
395}
396
397fn def(name: &str, description: &str, parameters: serde_json::Value) -> ToolDefinition {
398 ToolDefinition {
399 def_type: "function".to_string(),
400 function: crate::types::FunctionDef {
401 name: name.to_string(),
402 description: description.to_string(),
403 parameters,
404 },
405 }
406}
407
408fn require_session(runtime: &ToolRuntime) -> Result<&str, ToolResult> {
409 runtime.session_id.as_deref().ok_or_else(|| ToolResult {
410 content: "Error: thread tools require an active session".to_string(),
411 is_error: true,
412 })
413}
414
415fn resolve_thread_timeout_secs(args: &Value, default_timeout_secs: u64) -> u64 {
416 args.get("timeout")
417 .and_then(|v| v.as_u64())
418 .unwrap_or(default_timeout_secs)
419 .max(MIN_THREAD_TIMEOUT_SECS)
420}
421
422async fn mark_thread_active(runtime: &ToolRuntime, thread_name: &str) -> bool {
423 let mut active = runtime.active_threads.lock().await;
424 if active.contains(thread_name) {
425 false
426 } else {
427 active.insert(thread_name.to_string());
428 true
429 }
430}
431
432async fn unmark_thread_active(runtime: &ToolRuntime, thread_name: &str) {
433 runtime.active_threads.lock().await.remove(thread_name);
434}
435
436async fn is_thread_active(runtime: &ToolRuntime, thread_name: &str) -> bool {
437 runtime.active_threads.lock().await.contains(thread_name)
438}
439
440struct WorkerRun {
441 stdout: String,
442 stderr: String,
443 exit_code: i32,
444 timed_out: bool,
445 timeout_reason: Option<String>,
446}
447
448#[derive(Clone, Debug, PartialEq, Eq)]
449struct ActiveToolCallTrace {
450 name: String,
451 args_detail: Option<String>,
452}
453
454#[derive(Clone, Debug, PartialEq, Eq)]
455enum TimeoutLocation {
456 Startup,
457 ModelApi { iteration: usize },
458 ToolCall,
459 BetweenToolAndModel,
460 Finalizing,
461}
462
463impl Default for TimeoutLocation {
464 fn default() -> Self {
465 Self::Startup
466 }
467}
468
469#[derive(Default)]
470struct WorkerTimeoutTrace {
471 location: TimeoutLocation,
472 active_tool_calls: BTreeMap<String, ActiveToolCallTrace>,
473}
474
475impl WorkerTimeoutTrace {
476 fn observe(&mut self, event: &AgentEvent) {
477 match event {
478 AgentEvent::RunStarted { .. } => {
479 self.location = TimeoutLocation::Startup;
480 self.active_tool_calls.clear();
481 }
482 AgentEvent::ModelCallStarted { iteration, .. } => {
483 self.location = TimeoutLocation::ModelApi {
484 iteration: *iteration,
485 };
486 self.active_tool_calls.clear();
487 }
488 AgentEvent::ToolCallStarted {
489 call_id,
490 name,
491 args_detail,
492 ..
493 } => {
494 self.location = TimeoutLocation::ToolCall;
495 self.active_tool_calls.insert(
496 call_id.clone(),
497 ActiveToolCallTrace {
498 name: name.clone(),
499 args_detail: args_detail.clone(),
500 },
501 );
502 }
503 AgentEvent::ToolCallFinished { call_id, .. } => {
504 self.active_tool_calls.remove(call_id);
505 if self.active_tool_calls.is_empty() {
506 self.location = TimeoutLocation::BetweenToolAndModel;
507 } else {
508 self.location = TimeoutLocation::ToolCall;
509 }
510 }
511 AgentEvent::AssistantMessage { .. } | AgentEvent::RunFinished { .. } => {
512 self.location = TimeoutLocation::Finalizing;
513 self.active_tool_calls.clear();
514 }
515 AgentEvent::Error { .. }
516 | AgentEvent::ThreadLog { .. }
517 | AgentEvent::TerminalSnapshot { .. } => {}
518 AgentEvent::ThreadStarted { .. }
519 | AgentEvent::ThreadSpawned { .. }
520 | AgentEvent::ThreadFinished { .. } => {}
521 AgentEvent::StreamTextDelta { .. } | AgentEvent::StreamComplete { .. } => {}
522 AgentEvent::ModelIterationUsage { .. } => {}
523 AgentEvent::GoalContinuation { .. }
524 | AgentEvent::GoalTurnAccounted { .. }
525 | AgentEvent::GoalErrorTransition { .. } => {}
526 AgentEvent::LeanResumeTriggered { .. } => {}
527 }
528 }
529
530 fn timeout_reason(&self) -> String {
531 match &self.location {
532 TimeoutLocation::ModelApi { iteration } => format!(
533 "The thread timed out at a call to the model API.\nModel call: iteration {}",
534 iteration
535 ),
536 TimeoutLocation::ToolCall if !self.active_tool_calls.is_empty() => {
537 if self.active_tool_calls.len() == 1 {
538 let (call_id, call) = self.active_tool_calls.iter().next().unwrap();
539 return format!(
540 "The thread timed out at a tool call.\nTool call: {} {}\narguments: {}",
541 call.name,
542 call_id,
543 call.args_detail.as_deref().unwrap_or("<not captured>")
544 );
545 }
546
547 let mut reason = String::from("The thread timed out at tool calls:");
548 for (call_id, call) in &self.active_tool_calls {
549 reason.push_str(&format!("\n- {} {}", call.name, call_id));
550 match call.args_detail.as_deref() {
551 Some(args_detail) => {
552 reason.push_str(&format!("\n arguments: {}", args_detail));
553 }
554 None => reason.push_str("\n arguments: <not captured>"),
555 }
556 }
557 reason
558 }
559 TimeoutLocation::BetweenToolAndModel => {
560 "The thread timed out after tool call completion while preparing the next model API call."
561 .to_string()
562 }
563 TimeoutLocation::Finalizing => {
564 "The thread timed out after producing a final response while the worker was exiting."
565 .to_string()
566 }
567 TimeoutLocation::Startup | TimeoutLocation::ToolCall => {
568 "The thread timed out before entering a model API call or tool call.".to_string()
569 }
570 }
571 }
572}
573
574async fn run_worker(
575 runtime: &ToolRuntime,
576 client: &ModelClient,
577 session_id: &str,
578 thread_name: &str,
579 action: &str,
580 source_threads: &[String],
581 timeout_secs: u64,
582) -> std::io::Result<WorkerRun> {
583 let executable = runtime.worker_executable.clone().ok_or_else(|| {
584 std::io::Error::new(
585 std::io::ErrorKind::NotFound,
586 "worker executable path is not configured",
587 )
588 })?;
589 let executable_display = executable.display().to_string();
590 let cwd = std::env::current_dir()?;
591 tracing::debug!(
592 thread_name = %thread_name,
593 session_id = %session_id,
594 executable = %executable_display,
595 cwd = %cwd.display(),
596 sandboxed = runtime.sandbox.is_some(),
597 source_threads = ?source_threads,
598 timeout_secs,
599 "resolved managed worker spawn context"
600 );
601 let mut command = Command::new(executable);
602 command
603 .arg("__worker")
604 .arg("--session-id")
605 .arg(session_id)
606 .arg("--thread-name")
607 .arg(thread_name)
608 .arg("--action")
609 .arg(action)
610 .arg("--api-model")
611 .arg(client.model.as_str())
612 .arg("--api-base-url")
613 .arg(client.base_url())
614 .arg("--backend")
615 .arg(client.backend().as_str())
616 .arg("--store-path")
617 .arg(runtime.store_path.as_os_str())
618 .stdout(Stdio::piped())
619 .stderr(Stdio::piped());
620
621 if let Some(reasoning_effort) = client.reasoning_effort() {
622 command.arg("--effort").arg(reasoning_effort.as_str());
623 }
624
625 for source_thread in source_threads {
626 command.arg("--source-thread").arg(source_thread);
627 }
628 if let Some(sandbox) = &runtime.sandbox {
629 command.args(sandbox.worker_cli_args());
630 }
631 isolate_process_group(&mut command);
632
633 let mut child = command.spawn().map_err(|error| {
634 tracing::error!(
635 thread_name = %thread_name,
636 session_id = %session_id,
637 executable = %executable_display,
638 cwd = %cwd.display(),
639 sandboxed = runtime.sandbox.is_some(),
640 error = %error,
641 "managed worker spawn failed"
642 );
643 error
644 })?;
645
646 runtime.event_sink.emit(AgentEvent::ThreadSpawned {
647 name: thread_name.to_string(),
648 executable: executable_display.clone(),
649 cwd: cwd.display().to_string(),
650 sandboxed: runtime.sandbox.is_some(),
651 });
652 tracing::info!(
653 thread_name = %thread_name,
654 session_id = %session_id,
655 pid = ?child.id(),
656 "managed worker process spawned"
657 );
658
659 let timeout_trace = Arc::new(Mutex::new(WorkerTimeoutTrace::default()));
660 let stderr = child.stderr.take().unwrap();
661 let event_sink = runtime.event_sink.clone();
662 let thread_name_for_logs = thread_name.to_string();
663 let timeout_trace_for_logs = timeout_trace.clone();
664 let terminal_manager = runtime.terminal_manager.clone();
665 let thread_name_for_terminal_events = thread_name.to_string();
666 let stderr_handle = tokio::spawn(async move {
667 let reader = BufReader::new(stderr);
668 let mut lines = reader.lines();
669 let mut output = String::new();
670 while let Ok(Some(line)) = lines.next_line().await {
671 if let Some(event) = decode_stderr_event(&line) {
672 timeout_trace_for_logs.lock().await.observe(&event);
673 event_sink.emit(event);
674 } else {
675 let terminals = terminal_manager.list().await;
676 event_sink.emit(AgentEvent::TerminalSnapshot {
677 thread_name: Some(thread_name_for_terminal_events.clone()),
678 terminals,
679 });
680 event_sink.emit(AgentEvent::ThreadLog {
681 name: thread_name_for_logs.clone(),
682 line: line.clone(),
683 });
684 if !output.is_empty() {
685 output.push('\n');
686 }
687 output.push_str(&line);
688 }
689 }
690 output
691 });
692
693 let stdout = child.stdout.take().unwrap();
694 let stdout_handle = tokio::spawn(async move {
695 let reader = BufReader::new(stdout);
696 let mut lines = reader.lines();
697 let mut output = String::new();
698 while let Ok(Some(line)) = lines.next_line().await {
699 if !output.is_empty() {
700 output.push('\n');
701 }
702 output.push_str(&line);
703 }
704 output
705 });
706
707 let status = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
708 let timed_out = status.is_err();
709 if timed_out {
710 terminate_child_tree(&mut child).await;
711 }
712
713 let stderr = stderr_handle.await.unwrap_or_default();
714 let stdout = stdout_handle.await.unwrap_or_default();
715 let timeout_reason = if timed_out {
716 Some(timeout_trace.lock().await.timeout_reason())
717 } else {
718 None
719 };
720 let exit_code = match status {
721 Ok(wait_result) => wait_result?.code().unwrap_or(-1),
722 Err(_) => -1,
723 };
724
725 Ok(WorkerRun {
726 stdout,
727 stderr,
728 exit_code,
729 timed_out,
730 timeout_reason,
731 })
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use serde_json::json;
738
739 #[test]
740 fn thread_timeout_defaults_to_one_hour() {
741 assert_eq!(
742 resolve_thread_timeout_secs(&json!({}), DEFAULT_THREAD_TIMEOUT_SECS),
743 60 * 60
744 );
745 }
746
747 #[test]
748 fn thread_timeout_is_clamped_to_thirty_minutes() {
749 assert_eq!(resolve_thread_timeout_secs(&json!({}), 10), 30 * 60);
750 assert_eq!(
751 resolve_thread_timeout_secs(&json!({ "timeout": 20 }), DEFAULT_THREAD_TIMEOUT_SECS),
752 30 * 60
753 );
754 assert_eq!(
755 resolve_thread_timeout_secs(&json!({ "timeout": 7200 }), DEFAULT_THREAD_TIMEOUT_SECS),
756 7200
757 );
758 }
759
760 #[test]
761 fn timeout_trace_reports_model_api_location() {
762 let mut trace = WorkerTimeoutTrace::default();
763 trace.observe(&AgentEvent::ModelCallStarted {
764 thread_name: Some("impl/auth".to_string()),
765 iteration: 2,
766 });
767
768 assert_eq!(
769 trace.timeout_reason(),
770 "The thread timed out at a call to the model API.\nModel call: iteration 2"
771 );
772 }
773
774 #[test]
775 fn timeout_trace_reports_active_tool_call_details() {
776 let mut trace = WorkerTimeoutTrace::default();
777 trace.observe(&AgentEvent::ToolCallStarted {
778 thread_name: Some("impl/auth".to_string()),
779 call_id: "call_123".to_string(),
780 name: "exec_command".to_string(),
781 args_preview: "cargo test -p sac".to_string(),
782 args_detail: Some(
783 r#"{"cmd":"cargo test -p sac","tty":false,"yield_time_ms":300000}"#.to_string(),
784 ),
785 });
786
787 assert_eq!(
788 trace.timeout_reason(),
789 "The thread timed out at a tool call.\nTool call: exec_command call_123\narguments: {\"cmd\":\"cargo test -p sac\",\"tty\":false,\"yield_time_ms\":300000}"
790 );
791 }
792
793 #[test]
794 fn timeout_trace_ignores_thread_spawned_event() {
795 let mut trace = WorkerTimeoutTrace::default();
796 trace.observe(&AgentEvent::ThreadSpawned {
797 name: "impl/auth".to_string(),
798 executable: "/home/secemp9/.local/bin/sac".to_string(),
799 cwd: "/workspace/project".to_string(),
800 sandboxed: false,
801 });
802
803 assert_eq!(
804 trace.timeout_reason(),
805 "The thread timed out before entering a model API call or tool call."
806 );
807 }
808}