1use async_trait::async_trait;
2use parking_lot::RwLock;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::Digest;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::io::AsyncReadExt;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum ToolProviderType {
17 #[default]
18 Builtin,
19 Yaml,
20 Process,
21 Mcp,
22 Wasm,
23 Http,
24 Custom,
25}
26
27impl ToolProviderType {
28 pub fn default_trust_level(&self) -> TrustLevel {
29 match self {
30 ToolProviderType::Builtin => TrustLevel::Full,
31 ToolProviderType::Yaml => TrustLevel::High,
32 ToolProviderType::Process => TrustLevel::Medium,
33 ToolProviderType::Mcp => TrustLevel::Medium,
34 ToolProviderType::Custom => TrustLevel::Medium,
35 ToolProviderType::Wasm => TrustLevel::Sandboxed,
36 ToolProviderType::Http => TrustLevel::Low,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum TrustLevel {
44 Low,
45 Sandboxed,
46 #[default]
47 Medium,
48 High,
49 Full,
50}
51
52impl PartialOrd for TrustLevel {
53 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
54 Some(self.cmp(other))
55 }
56}
57
58impl Ord for TrustLevel {
59 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
60 self.as_u8().cmp(&other.as_u8())
61 }
62}
63
64impl TrustLevel {
65 fn as_u8(&self) -> u8 {
66 match self {
67 TrustLevel::Low => 0,
68 TrustLevel::Sandboxed => 1,
69 TrustLevel::Medium => 2,
70 TrustLevel::High => 3,
71 TrustLevel::Full => 4,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct ToolAliases {
78 #[serde(default)]
79 pub names: HashMap<String, String>,
80 #[serde(default)]
81 pub descriptions: HashMap<String, String>,
82 #[serde(default)]
83 pub parameter_aliases: HashMap<String, HashMap<String, String>>,
84}
85
86impl ToolAliases {
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 pub fn with_name(mut self, lang: impl Into<String>, name: impl Into<String>) -> Self {
92 self.names.insert(lang.into(), name.into());
93 self
94 }
95
96 pub fn with_description(mut self, lang: impl Into<String>, desc: impl Into<String>) -> Self {
97 self.descriptions.insert(lang.into(), desc.into());
98 self
99 }
100
101 pub fn get_name(&self, lang: &str) -> Option<&str> {
102 self.names.get(lang).map(|s| s.as_str())
103 }
104
105 pub fn get_description(&self, lang: &str) -> Option<&str> {
106 self.descriptions.get(lang).map(|s| s.as_str())
107 }
108
109 pub fn is_empty(&self) -> bool {
110 self.names.is_empty() && self.descriptions.is_empty() && self.parameter_aliases.is_empty()
111 }
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct ToolMetadata {
116 #[serde(default)]
117 pub tags: Vec<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub estimated_duration_ms: Option<u64>,
121
122 #[serde(default)]
123 pub has_side_effects: bool,
124
125 #[serde(default)]
126 pub requires_network: bool,
127
128 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
129 pub custom: HashMap<String, Value>,
130}
131
132impl ToolMetadata {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
138 self.tags = tags;
139 self
140 }
141
142 pub fn with_side_effects(mut self) -> Self {
143 self.has_side_effects = true;
144 self
145 }
146
147 pub fn with_network(mut self) -> Self {
148 self.requires_network = true;
149 self
150 }
151}
152
153#[derive(Debug, Clone, Default)]
154pub struct ToolContext {
155 pub session_id: Option<String>,
156 pub user_id: Option<String>,
157 pub state_name: Option<String>,
158 pub language: Option<String>,
159 pub extra: HashMap<String, Value>,
160}
161
162impl ToolContext {
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
168 self.session_id = Some(session_id.into());
169 self
170 }
171
172 pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
173 self.user_id = Some(user_id.into());
174 self
175 }
176
177 pub fn with_state(mut self, state_name: impl Into<String>) -> Self {
178 self.state_name = Some(state_name.into());
179 self
180 }
181
182 pub fn with_language(mut self, language: impl Into<String>) -> Self {
183 self.language = Some(language.into());
184 self
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
190pub struct FileVersionEvidence {
191 pub path: String,
193 pub sha256: String,
195 pub size_bytes: u64,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub modified_unix_ms: Option<u128>,
200}
201
202#[derive(Clone, Default)]
204pub struct FileVersionStore {
205 inner: Arc<RwLock<HashMap<String, FileVersionEvidence>>>,
206}
207
208impl FileVersionStore {
209 pub fn record(&self, evidence: FileVersionEvidence) {
211 self.inner.write().insert(evidence.path.clone(), evidence);
212 }
213
214 pub fn get(&self, path: impl AsRef<Path>) -> Option<FileVersionEvidence> {
216 let key = normalize_version_path(path.as_ref());
217 self.inner.read().get(&key).cloned()
218 }
219
220 pub fn matches(&self, evidence: &FileVersionEvidence) -> bool {
222 self.inner
223 .read()
224 .get(&evidence.path)
225 .is_some_and(|stored| stored == evidence)
226 }
227}
228
229pub fn file_version_evidence(
231 path: impl AsRef<Path>,
232 bytes: &[u8],
233) -> std::io::Result<FileVersionEvidence> {
234 let path = path.as_ref();
235 let metadata = std::fs::metadata(path)?;
236 let modified_unix_ms = metadata
237 .modified()
238 .ok()
239 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
240 .map(|duration| duration.as_millis());
241 let mut hasher = sha2::Sha256::new();
242 sha2::Digest::update(&mut hasher, bytes);
243 let hash = sha2::Digest::finalize(hasher);
244 Ok(FileVersionEvidence {
245 path: normalize_version_path(path),
246 sha256: format!("{:x}", hash),
247 size_bytes: metadata.len(),
248 modified_unix_ms,
249 })
250}
251
252fn normalize_version_path(path: &Path) -> String {
253 let path = if path.is_absolute() {
254 path.to_path_buf()
255 } else {
256 std::env::current_dir()
257 .unwrap_or_else(|_| PathBuf::from("."))
258 .join(path)
259 };
260 path.components()
261 .collect::<PathBuf>()
262 .to_string_lossy()
263 .to_string()
264}
265
266pub type QuestionHandlerSlot = Arc<RwLock<Option<Arc<dyn QuestionHandler>>>>;
268
269#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
271pub struct QuestionRequest {
272 pub question: String,
274 #[serde(default)]
276 pub options: Vec<String>,
277 #[serde(default)]
279 pub multi_select: bool,
280 #[serde(default = "default_true")]
282 pub allow_other: bool,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub default: Option<Value>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub timeout_seconds: Option<u64>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
293pub struct QuestionResponse {
294 pub answered: bool,
296 #[serde(default)]
298 pub selected: Vec<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub other_text: Option<String>,
302 #[serde(default)]
304 pub timed_out: bool,
305 #[serde(default)]
307 pub unavailable: bool,
308}
309
310#[async_trait]
312pub trait QuestionHandler: Send + Sync {
313 async fn ask_question(&self, request: QuestionRequest) -> QuestionResponse;
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
319#[serde(rename_all = "snake_case")]
320pub enum DiagnosticSeverity {
321 Error,
322 Warning,
323 Info,
324 Hint,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
329pub struct DiagnosticsRequest {
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub path: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub severity: Option<DiagnosticSeverity>,
336 #[serde(default)]
338 pub max_results: Option<usize>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct DiagnosticItem {
344 pub path: String,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub line: Option<u32>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub column: Option<u32>,
352 pub severity: DiagnosticSeverity,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub source: Option<String>,
357 pub message: String,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub code: Option<String>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
366pub struct DiagnosticsResponse {
367 pub available: bool,
369 #[serde(default)]
371 pub diagnostics: Vec<DiagnosticItem>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub message: Option<String>,
375}
376
377#[async_trait]
379pub trait DiagnosticsProvider: Send + Sync {
380 fn is_available(&self) -> bool {
382 true
383 }
384
385 async fn diagnostics(&self, request: DiagnosticsRequest) -> DiagnosticsResponse;
387}
388
389#[derive(Debug, Default)]
391pub struct UnavailableDiagnosticsProvider;
392
393#[async_trait]
394impl DiagnosticsProvider for UnavailableDiagnosticsProvider {
395 fn is_available(&self) -> bool {
396 false
397 }
398
399 async fn diagnostics(&self, _request: DiagnosticsRequest) -> DiagnosticsResponse {
400 DiagnosticsResponse {
401 available: false,
402 diagnostics: Vec::new(),
403 message: Some("diagnostics provider is unavailable".to_string()),
404 }
405 }
406}
407
408#[derive(Debug, Clone)]
410pub struct StaticDiagnosticsProvider {
411 diagnostics: Vec<DiagnosticItem>,
412 available: bool,
413}
414
415impl StaticDiagnosticsProvider {
416 pub fn new(diagnostics: Vec<DiagnosticItem>) -> Self {
418 Self {
419 diagnostics,
420 available: true,
421 }
422 }
423
424 pub fn with_availability(diagnostics: Vec<DiagnosticItem>, available: bool) -> Self {
426 Self {
427 diagnostics,
428 available,
429 }
430 }
431}
432
433#[async_trait]
434impl DiagnosticsProvider for StaticDiagnosticsProvider {
435 fn is_available(&self) -> bool {
436 self.available
437 }
438
439 async fn diagnostics(&self, request: DiagnosticsRequest) -> DiagnosticsResponse {
440 if !self.available {
441 return DiagnosticsResponse {
442 available: false,
443 diagnostics: Vec::new(),
444 message: Some("diagnostics provider is unavailable".to_string()),
445 };
446 }
447
448 let mut diagnostics: Vec<DiagnosticItem> = self
449 .diagnostics
450 .iter()
451 .filter(|item| {
452 request
453 .path
454 .as_ref()
455 .is_none_or(|path| item.path.starts_with(path))
456 })
457 .filter(|item| {
458 request
459 .severity
460 .as_ref()
461 .is_none_or(|severity| &item.severity == severity)
462 })
463 .cloned()
464 .collect();
465
466 if let Some(max_results) = request.max_results {
467 diagnostics.truncate(max_results);
468 }
469
470 DiagnosticsResponse {
471 available: true,
472 diagnostics,
473 message: None,
474 }
475 }
476}
477
478pub type DiagnosticsProviderSlot = Arc<RwLock<Arc<dyn DiagnosticsProvider>>>;
480
481#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum WebSearchSafeSearch {
485 Off,
486 #[default]
487 Moderate,
488 Strict,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
493pub struct WebSearchRequest {
494 pub query: String,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub max_results: Option<usize>,
499 #[serde(default)]
501 pub include_domains: Vec<String>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub language: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub region: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub safe_search: Option<WebSearchSafeSearch>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
515pub struct WebSearchResultItem {
516 pub title: String,
518 pub url: String,
520 #[serde(default)]
522 pub snippet: String,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub source: Option<String>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub published_at: Option<String>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
533pub struct WebSearchResponse {
534 pub available: bool,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub provider: Option<String>,
539 #[serde(default)]
541 pub results: Vec<WebSearchResultItem>,
542 #[serde(default)]
544 pub truncated: bool,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub message: Option<String>,
548}
549
550#[async_trait]
552pub trait WebSearchProvider: Send + Sync {
553 fn is_available(&self) -> bool {
555 true
556 }
557
558 async fn search(&self, request: WebSearchRequest) -> WebSearchResponse;
560}
561
562#[derive(Debug, Default)]
564pub struct UnavailableWebSearchProvider;
565
566#[async_trait]
567impl WebSearchProvider for UnavailableWebSearchProvider {
568 fn is_available(&self) -> bool {
569 false
570 }
571
572 async fn search(&self, _request: WebSearchRequest) -> WebSearchResponse {
573 WebSearchResponse {
574 available: false,
575 results: Vec::new(),
576 message: Some("web search provider is unavailable".to_string()),
577 ..WebSearchResponse::default()
578 }
579 }
580}
581
582#[derive(Debug, Clone, Default)]
584pub struct StaticWebSearchProvider {
585 responses: HashMap<String, WebSearchResponse>,
586 available: bool,
587}
588
589impl StaticWebSearchProvider {
590 pub fn new(responses: HashMap<String, WebSearchResponse>) -> Self {
592 Self {
593 responses,
594 available: true,
595 }
596 }
597
598 pub fn with_availability(
600 responses: HashMap<String, WebSearchResponse>,
601 available: bool,
602 ) -> Self {
603 Self {
604 responses,
605 available,
606 }
607 }
608}
609
610#[async_trait]
611impl WebSearchProvider for StaticWebSearchProvider {
612 fn is_available(&self) -> bool {
613 self.available
614 }
615
616 async fn search(&self, request: WebSearchRequest) -> WebSearchResponse {
617 if !self.available {
618 return WebSearchResponse {
619 available: false,
620 message: Some("web search provider is unavailable".to_string()),
621 ..WebSearchResponse::default()
622 };
623 }
624
625 let mut response = self
626 .responses
627 .get(&request.query)
628 .cloned()
629 .unwrap_or_else(|| WebSearchResponse {
630 available: true,
631 provider: Some("static".to_string()),
632 results: Vec::new(),
633 message: Some("no fixture search results matched the query".to_string()),
634 ..WebSearchResponse::default()
635 });
636 response.available = true;
637 if response.provider.is_none() {
638 response.provider = Some("static".to_string());
639 }
640 if !request.include_domains.is_empty() {
641 let before = response.results.len();
642 response
643 .results
644 .retain(|item| result_matches_domains(&item.url, &request.include_domains));
645 response.truncated |= response.results.len() != before;
646 }
647 if let Some(max_results) = request.max_results
648 && response.results.len() > max_results
649 {
650 response.results.truncate(max_results);
651 response.truncated = true;
652 }
653 response
654 }
655}
656
657pub type WebSearchProviderSlot = Arc<RwLock<Arc<dyn WebSearchProvider>>>;
659
660fn result_matches_domains(url: &str, domains: &[String]) -> bool {
661 let host = reqwest::Url::parse(url)
662 .ok()
663 .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase))
664 .unwrap_or_else(|| url.to_ascii_lowercase());
665 domains.iter().any(|domain| {
666 let domain = domain.trim().to_ascii_lowercase();
667 host == domain || host.ends_with(&format!(".{}", domain))
668 })
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
673pub struct CommandRequest {
674 pub argv: Vec<String>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub cwd: Option<String>,
679 #[serde(default)]
681 pub env: HashMap<String, String>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub timeout_ms: Option<u64>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub max_output_chars: Option<usize>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub reason: Option<String>,
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
695pub struct CommandResponse {
696 pub success: bool,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub exit_code: Option<i32>,
701 pub termination: String,
703 #[serde(default)]
705 pub stdout: String,
706 #[serde(default)]
708 pub stderr: String,
709 #[serde(default)]
711 pub combined_output: String,
712 #[serde(default)]
714 pub truncated: bool,
715 #[serde(default)]
717 pub timed_out: bool,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub cwd: Option<String>,
721 #[serde(default)]
723 pub argv_redacted: Vec<String>,
724}
725
726#[async_trait]
728pub trait CommandRunner: Send + Sync {
729 fn is_available(&self) -> bool {
731 true
732 }
733
734 async fn run_command(
736 &self,
737 request: CommandRequest,
738 ctx: ai_agents_core::ToolExecutionContext,
739 ) -> CommandResponse;
740}
741
742#[derive(Debug, Default)]
744pub struct UnavailableCommandRunner;
745
746#[async_trait]
747impl CommandRunner for UnavailableCommandRunner {
748 fn is_available(&self) -> bool {
749 false
750 }
751
752 async fn run_command(
753 &self,
754 request: CommandRequest,
755 _ctx: ai_agents_core::ToolExecutionContext,
756 ) -> CommandResponse {
757 CommandResponse {
758 success: false,
759 termination: "unavailable".to_string(),
760 cwd: request.cwd,
761 argv_redacted: redact_argv(&request.argv),
762 ..CommandResponse::default()
763 }
764 }
765}
766
767#[derive(Debug, Clone, Default)]
769pub struct StaticCommandRunner {
770 responses: HashMap<Vec<String>, CommandResponse>,
771 available: bool,
772}
773
774impl StaticCommandRunner {
775 pub fn new(responses: HashMap<Vec<String>, CommandResponse>) -> Self {
777 Self {
778 responses,
779 available: true,
780 }
781 }
782
783 pub fn with_availability(
785 responses: HashMap<Vec<String>, CommandResponse>,
786 available: bool,
787 ) -> Self {
788 Self {
789 responses,
790 available,
791 }
792 }
793}
794
795#[async_trait]
796impl CommandRunner for StaticCommandRunner {
797 fn is_available(&self) -> bool {
798 self.available
799 }
800
801 async fn run_command(
802 &self,
803 request: CommandRequest,
804 _ctx: ai_agents_core::ToolExecutionContext,
805 ) -> CommandResponse {
806 if !self.available {
807 return CommandResponse {
808 success: false,
809 termination: "unavailable".to_string(),
810 cwd: request.cwd,
811 argv_redacted: redact_argv(&request.argv),
812 ..CommandResponse::default()
813 };
814 }
815 self.responses
816 .get(&request.argv)
817 .cloned()
818 .unwrap_or_else(|| CommandResponse {
819 success: false,
820 exit_code: Some(127),
821 termination: "not_found".to_string(),
822 stderr: "mock command not found".to_string(),
823 combined_output: "mock command not found".to_string(),
824 cwd: request.cwd,
825 argv_redacted: redact_argv(&request.argv),
826 ..CommandResponse::default()
827 })
828 }
829}
830
831#[derive(Debug, Clone, Default)]
833pub struct ProcessCommandRunner;
834
835#[async_trait]
836impl CommandRunner for ProcessCommandRunner {
837 async fn run_command(
838 &self,
839 request: CommandRequest,
840 ctx: ai_agents_core::ToolExecutionContext,
841 ) -> CommandResponse {
842 if request.argv.is_empty() {
843 return CommandResponse {
844 success: false,
845 termination: "error".to_string(),
846 stderr: "argv must not be empty".to_string(),
847 combined_output: "argv must not be empty".to_string(),
848 cwd: request.cwd,
849 argv_redacted: Vec::new(),
850 ..CommandResponse::default()
851 };
852 }
853 let mut command = tokio::process::Command::new(&request.argv[0]);
854 command.args(&request.argv[1..]);
855 command
856 .stdin(Stdio::null())
857 .stdout(Stdio::piped())
858 .stderr(Stdio::piped())
859 .env_clear();
860 command.kill_on_drop(true);
861 if let Some(cwd) = &request.cwd {
862 command.current_dir(cwd);
863 }
864 for (key, value) in &request.env {
865 command.env(key, value);
866 }
867 let timeout_ms = request
868 .timeout_ms
869 .or(ctx.limits.timeout_ms)
870 .unwrap_or(30_000);
871 let max_output_chars = request
872 .max_output_chars
873 .or(ctx.limits.max_output_chars)
874 .unwrap_or(20_000);
875 let mut child = match command.spawn() {
876 Ok(child) => child,
877 Err(error) => {
878 return CommandResponse {
879 success: false,
880 termination: "error".to_string(),
881 stderr: error.to_string(),
882 combined_output: error.to_string(),
883 cwd: request.cwd,
884 argv_redacted: redact_argv(&request.argv),
885 ..CommandResponse::default()
886 };
887 }
888 };
889 let output_byte_cap = max_output_chars.saturating_mul(4).max(1);
890 let stdout = child.stdout.take();
891 let stderr = child.stderr.take();
892 let stdout_task = tokio::spawn(read_pipe_bounded(stdout, output_byte_cap));
893 let stderr_task = tokio::spawn(read_pipe_bounded(stderr, output_byte_cap));
894 let status = tokio::time::timeout(Duration::from_millis(timeout_ms), child.wait()).await;
895 match status {
896 Ok(Ok(status)) => {
897 let (stdout, stdout_truncated) = stdout_task.await.unwrap_or_default();
898 let (stderr, stderr_truncated) = stderr_task.await.unwrap_or_default();
899 command_output_response(
900 status,
901 stdout,
902 stderr,
903 stdout_truncated || stderr_truncated,
904 request,
905 max_output_chars,
906 )
907 }
908 Ok(Err(error)) => CommandResponse {
909 success: false,
910 termination: "error".to_string(),
911 stderr: error.to_string(),
912 combined_output: error.to_string(),
913 cwd: request.cwd,
914 argv_redacted: redact_argv(&request.argv),
915 ..CommandResponse::default()
916 },
917 Err(_) => {
918 let _ = child.kill().await;
919 let _ = child.wait().await;
920 stdout_task.abort();
921 stderr_task.abort();
922 let message = "command timed out; process cleanup requested".to_string();
923 CommandResponse {
924 success: false,
925 termination: "timeout".to_string(),
926 stderr: message.clone(),
927 combined_output: message,
928 timed_out: true,
929 cwd: request.cwd,
930 argv_redacted: redact_argv(&request.argv),
931 ..CommandResponse::default()
932 }
933 }
934 }
935 }
936}
937
938pub type CommandRunnerSlot = Arc<RwLock<Arc<dyn CommandRunner>>>;
940
941async fn read_pipe_bounded<R>(pipe: Option<R>, max_bytes: usize) -> (Vec<u8>, bool)
942where
943 R: tokio::io::AsyncRead + Unpin,
944{
945 let Some(mut pipe) = pipe else {
946 return (Vec::new(), false);
947 };
948 let mut output = Vec::new();
949 let mut truncated = false;
950 let mut buffer = vec![0u8; 8192];
951 loop {
952 let read = match pipe.read(&mut buffer).await {
953 Ok(0) => break,
954 Ok(read) => read,
955 Err(_) => break,
956 };
957 let remaining = max_bytes.saturating_sub(output.len());
958 if remaining > 0 {
959 output.extend_from_slice(&buffer[..read.min(remaining)]);
960 }
961 if read > remaining {
962 truncated = true;
963 }
964 }
965 (output, truncated)
966}
967
968fn command_output_response(
969 status: std::process::ExitStatus,
970 stdout: Vec<u8>,
971 stderr: Vec<u8>,
972 pre_truncated: bool,
973 request: CommandRequest,
974 max_output_chars: usize,
975) -> CommandResponse {
976 let stdout = String::from_utf8_lossy(&stdout).to_string();
977 let stderr = String::from_utf8_lossy(&stderr).to_string();
978 let combined = if stderr.is_empty() {
979 stdout.clone()
980 } else if stdout.is_empty() {
981 stderr.clone()
982 } else {
983 format!("{}\n{}", stdout, stderr)
984 };
985 let (stdout, stdout_truncated) = truncate_chars(stdout, max_output_chars);
986 let (stderr, stderr_truncated) = truncate_chars(stderr, max_output_chars);
987 let (combined_output, combined_truncated) = truncate_chars(combined, max_output_chars);
988 CommandResponse {
989 success: status.success(),
990 exit_code: status.code(),
991 termination: "exited".to_string(),
992 stdout,
993 stderr,
994 combined_output,
995 truncated: pre_truncated || stdout_truncated || stderr_truncated || combined_truncated,
996 timed_out: false,
997 cwd: request.cwd,
998 argv_redacted: redact_argv(&request.argv),
999 }
1000}
1001
1002fn truncate_chars(value: String, max_chars: usize) -> (String, bool) {
1003 let mut chars = value.chars();
1004 let truncated: String = chars.by_ref().take(max_chars).collect();
1005 if chars.next().is_some() {
1006 (truncated, true)
1007 } else {
1008 (value, false)
1009 }
1010}
1011
1012fn redact_argv(argv: &[String]) -> Vec<String> {
1013 let mut redacted = Vec::with_capacity(argv.len());
1014 let mut redact_next = false;
1015 for arg in argv {
1016 let lower = arg.to_ascii_lowercase();
1017 let sensitive = lower.contains("token")
1018 || lower.contains("secret")
1019 || lower.contains("password")
1020 || lower.contains("apikey")
1021 || lower.contains("api-key");
1022 if redact_next || sensitive {
1023 redacted.push("[redacted]".to_string());
1024 } else {
1025 redacted.push(arg.clone());
1026 }
1027 redact_next = matches!(
1028 lower.as_str(),
1029 "--token" | "--secret" | "--password" | "--api-key"
1030 );
1031 }
1032 redacted
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1037#[serde(rename_all = "snake_case")]
1038pub enum TodoStatus {
1039 #[default]
1040 Pending,
1041 InProgress,
1042 Completed,
1043 Cancelled,
1044}
1045
1046#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1048pub struct TodoItem {
1049 pub id: String,
1051 pub content: String,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub active_form: Option<String>,
1056 #[serde(default)]
1058 pub status: TodoStatus,
1059}
1060
1061#[derive(Clone, Default)]
1063pub struct TodoStore {
1064 inner: Arc<RwLock<Vec<TodoItem>>>,
1065}
1066
1067impl TodoStore {
1068 pub fn list(&self) -> Vec<TodoItem> {
1070 self.inner.read().clone()
1071 }
1072
1073 pub fn set(&self, items: Vec<TodoItem>) {
1075 *self.inner.write() = items;
1076 }
1077
1078 pub fn update(
1080 &self,
1081 id: &str,
1082 content: Option<String>,
1083 active_form: Option<String>,
1084 status: Option<TodoStatus>,
1085 ) -> bool {
1086 let mut items = self.inner.write();
1087 let Some(item) = items.iter_mut().find(|item| item.id == id) else {
1088 return false;
1089 };
1090 if let Some(content) = content {
1091 item.content = content;
1092 }
1093 if let Some(active_form) = active_form {
1094 item.active_form = Some(active_form);
1095 }
1096 if let Some(status) = status {
1097 item.status = status;
1098 }
1099 true
1100 }
1101
1102 pub fn clear(&self) {
1104 self.inner.write().clear();
1105 }
1106}
1107
1108fn default_true() -> bool {
1109 true
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn process_command_runner_timeout_helper() {
1118 if std::env::var("AI_AGENTS_PROCESS_TIMEOUT_HELPER").as_deref() != Ok("1") {
1119 return;
1120 }
1121 let Ok(started_path) = std::env::var("AI_AGENTS_PROCESS_TIMEOUT_STARTED") else {
1122 return;
1123 };
1124 let Ok(completed_path) = std::env::var("AI_AGENTS_PROCESS_TIMEOUT_COMPLETED") else {
1125 return;
1126 };
1127 std::fs::write(started_path, b"started").unwrap();
1128 std::thread::sleep(Duration::from_secs(10));
1129 std::fs::write(completed_path, b"completed").unwrap();
1130 }
1131
1132 #[tokio::test]
1133 async fn process_command_runner_timeout_kills_direct_child() {
1134 let directory = tempfile::tempdir().unwrap();
1135 let started_path = directory.path().join("started");
1136 let completed_path = directory.path().join("completed");
1137 let executable = std::env::current_exe().unwrap();
1138 let request = CommandRequest {
1139 argv: vec![
1140 executable.to_string_lossy().into_owned(),
1141 "types::tests::process_command_runner_timeout_helper".to_string(),
1142 "--exact".to_string(),
1143 ],
1144 env: HashMap::from([
1145 (
1146 "AI_AGENTS_PROCESS_TIMEOUT_HELPER".to_string(),
1147 "1".to_string(),
1148 ),
1149 (
1150 "AI_AGENTS_PROCESS_TIMEOUT_STARTED".to_string(),
1151 started_path.to_string_lossy().into_owned(),
1152 ),
1153 (
1154 "AI_AGENTS_PROCESS_TIMEOUT_COMPLETED".to_string(),
1155 completed_path.to_string_lossy().into_owned(),
1156 ),
1157 ]),
1158 timeout_ms: Some(2_000),
1159 ..CommandRequest::default()
1160 };
1161
1162 let response = ProcessCommandRunner
1163 .run_command(
1164 request,
1165 ai_agents_core::ToolExecutionContext::test("command"),
1166 )
1167 .await;
1168
1169 assert_eq!(response.termination, "timeout");
1170 assert!(response.timed_out);
1171 assert!(started_path.exists());
1172 assert!(!completed_path.exists());
1173 tokio::time::sleep(Duration::from_secs(2)).await;
1174 assert!(!completed_path.exists());
1175 }
1176
1177 #[test]
1178 fn test_provider_type_default() {
1179 let pt = ToolProviderType::default();
1180 assert_eq!(pt, ToolProviderType::Builtin);
1181 }
1182
1183 #[test]
1184 fn test_provider_type_trust_levels() {
1185 assert_eq!(
1186 ToolProviderType::Builtin.default_trust_level(),
1187 TrustLevel::Full
1188 );
1189 assert_eq!(
1190 ToolProviderType::Yaml.default_trust_level(),
1191 TrustLevel::High
1192 );
1193 assert_eq!(
1194 ToolProviderType::Wasm.default_trust_level(),
1195 TrustLevel::Sandboxed
1196 );
1197 assert_eq!(
1198 ToolProviderType::Http.default_trust_level(),
1199 TrustLevel::Low
1200 );
1201 }
1202
1203 #[test]
1204 fn test_trust_level_ordering() {
1205 assert!(TrustLevel::Full > TrustLevel::High);
1206 assert!(TrustLevel::High > TrustLevel::Medium);
1207 assert!(TrustLevel::Medium > TrustLevel::Sandboxed);
1208 assert!(TrustLevel::Sandboxed > TrustLevel::Low);
1209 }
1210
1211 #[test]
1212 fn test_tool_aliases() {
1213 let aliases = ToolAliases::new()
1214 .with_name("ko", "웹검색")
1215 .with_name("ja", "ウェブ検索")
1216 .with_description("ko", "웹에서 정보 검색");
1217
1218 assert_eq!(aliases.get_name("ko"), Some("웹검색"));
1219 assert_eq!(aliases.get_name("ja"), Some("ウェブ検索"));
1220 assert_eq!(aliases.get_name("en"), None);
1221 assert_eq!(aliases.get_description("ko"), Some("웹에서 정보 검색"));
1222 assert!(!aliases.is_empty());
1223 }
1224
1225 #[test]
1226 fn test_tool_metadata() {
1227 let metadata = ToolMetadata::new()
1228 .with_tags(vec!["network".to_string(), "api".to_string()])
1229 .with_side_effects()
1230 .with_network();
1231
1232 assert_eq!(metadata.tags.len(), 2);
1233 assert!(metadata.has_side_effects);
1234 assert!(metadata.requires_network);
1235 }
1236
1237 #[test]
1238 fn test_tool_context() {
1239 let ctx = ToolContext::new()
1240 .with_session("session123")
1241 .with_user("user456")
1242 .with_state("greeting")
1243 .with_language("ko");
1244
1245 assert_eq!(ctx.session_id, Some("session123".to_string()));
1246 assert_eq!(ctx.user_id, Some("user456".to_string()));
1247 assert_eq!(ctx.state_name, Some("greeting".to_string()));
1248 assert_eq!(ctx.language, Some("ko".to_string()));
1249 }
1250
1251 #[test]
1252 fn test_provider_type_serde() {
1253 let json = serde_json::to_string(&ToolProviderType::Builtin).unwrap();
1254 assert_eq!(json, "\"builtin\"");
1255
1256 let pt: ToolProviderType = serde_json::from_str("\"yaml\"").unwrap();
1257 assert_eq!(pt, ToolProviderType::Yaml);
1258 }
1259}