Skip to main content

ai_agents_tools/
types.rs

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/// Version evidence captured when file contents are read.
189#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
190pub struct FileVersionEvidence {
191    /// Normalized file path used as the version key.
192    pub path: String,
193    /// SHA-256 hash of the observed file bytes.
194    pub sha256: String,
195    /// File size in bytes at observation time.
196    pub size_bytes: u64,
197    /// Modified timestamp as milliseconds since Unix epoch when available.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub modified_unix_ms: Option<u128>,
200}
201
202/// Session-local file version store used for read-before-write checks.
203#[derive(Clone, Default)]
204pub struct FileVersionStore {
205    inner: Arc<RwLock<HashMap<String, FileVersionEvidence>>>,
206}
207
208impl FileVersionStore {
209    /// Record a file version under its normalized path key.
210    pub fn record(&self, evidence: FileVersionEvidence) {
211        self.inner.write().insert(evidence.path.clone(), evidence);
212    }
213
214    /// Return the version evidence stored for a path.
215    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    /// Return true when the stored version matches the supplied evidence.
221    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
229/// Build file version evidence from observed bytes and metadata.
230pub 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
266/// Shared slot used by `ask_user` to find the active host handler.
267pub type QuestionHandlerSlot = Arc<RwLock<Option<Arc<dyn QuestionHandler>>>>;
268
269/// Structured question sent from a tool call to the host UI.
270#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
271pub struct QuestionRequest {
272    /// Text shown to the user.
273    pub question: String,
274    /// Selectable choices shown by CLI and TUI hosts.
275    #[serde(default)]
276    pub options: Vec<String>,
277    /// Allows more than one selected option.
278    #[serde(default)]
279    pub multi_select: bool,
280    /// Allows free text when the host supports it.
281    #[serde(default = "default_true")]
282    pub allow_other: bool,
283    /// Fallback value used by non-interactive hosts.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub default: Option<Value>,
286    /// Max seconds to wait before using fallback behavior.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub timeout_seconds: Option<u64>,
289}
290
291/// Answer returned by an interactive or fallback question handler.
292#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
293pub struct QuestionResponse {
294    /// Whether the request received a usable answer.
295    pub answered: bool,
296    /// Selected option labels, empty when free text was used.
297    #[serde(default)]
298    pub selected: Vec<String>,
299    /// Free text answer when the host allowed it.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub other_text: Option<String>,
302    /// Whether the handler timed out.
303    #[serde(default)]
304    pub timed_out: bool,
305    /// Whether no interactive host was available.
306    #[serde(default)]
307    pub unavailable: bool,
308}
309
310/// Host bridge for asking preference or clarification questions.
311#[async_trait]
312pub trait QuestionHandler: Send + Sync {
313    /// Ask a structured question and return a bounded answer.
314    async fn ask_question(&self, request: QuestionRequest) -> QuestionResponse;
315}
316
317/// Severity assigned to a compiler, linter, or editor diagnostic.
318#[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/// Query sent to a diagnostics provider.
328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
329pub struct DiagnosticsRequest {
330    /// Optional file or directory path filter.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub path: Option<String>,
333    /// Optional severity filter.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub severity: Option<DiagnosticSeverity>,
336    /// Maximum returned diagnostics.
337    #[serde(default)]
338    pub max_results: Option<usize>,
339}
340
341/// One diagnostic item returned by a host provider.
342#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct DiagnosticItem {
344    /// File path associated with the diagnostic.
345    pub path: String,
346    /// One-based line number when available.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub line: Option<u32>,
349    /// One-based column number when available.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub column: Option<u32>,
352    /// Severity reported by the host.
353    pub severity: DiagnosticSeverity,
354    /// Tool, compiler, linter, or LSP source name.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub source: Option<String>,
357    /// Human-readable diagnostic message.
358    pub message: String,
359    /// Optional provider-specific error or warning code.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub code: Option<String>,
362}
363
364/// Diagnostics returned by a host provider.
365#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
366pub struct DiagnosticsResponse {
367    /// Whether a provider was available for this request.
368    pub available: bool,
369    /// Diagnostics matching the request.
370    #[serde(default)]
371    pub diagnostics: Vec<DiagnosticItem>,
372    /// Optional message for unavailable or partial results.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub message: Option<String>,
375}
376
377/// Provider hook for compiler, linter, or editor diagnostics.
378#[async_trait]
379pub trait DiagnosticsProvider: Send + Sync {
380    /// Return whether this provider can serve diagnostics now.
381    fn is_available(&self) -> bool {
382        true
383    }
384
385    /// Return diagnostics for the supplied request.
386    async fn diagnostics(&self, request: DiagnosticsRequest) -> DiagnosticsResponse;
387}
388
389/// Diagnostics provider used when the host has not installed one.
390#[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/// Static diagnostics provider used by tests and eval fixtures.
409#[derive(Debug, Clone)]
410pub struct StaticDiagnosticsProvider {
411    diagnostics: Vec<DiagnosticItem>,
412    available: bool,
413}
414
415impl StaticDiagnosticsProvider {
416    /// Create a provider that returns a deterministic diagnostics list.
417    pub fn new(diagnostics: Vec<DiagnosticItem>) -> Self {
418        Self {
419            diagnostics,
420            available: true,
421        }
422    }
423
424    /// Create a provider with explicit availability.
425    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
478/// Shared slot used by `diagnostics` to find the active provider.
479pub type DiagnosticsProviderSlot = Arc<RwLock<Arc<dyn DiagnosticsProvider>>>;
480
481/// Safe-search preference for provider-neutral web search.
482#[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/// Search request sent to a host web-search provider.
492#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
493pub struct WebSearchRequest {
494    /// Search query sent to the provider.
495    pub query: String,
496    /// Maximum result count requested by the model.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub max_results: Option<usize>,
499    /// Optional domain filters requested by the model or eval fixture.
500    #[serde(default)]
501    pub include_domains: Vec<String>,
502    /// Optional language hint such as en or ja.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub language: Option<String>,
505    /// Optional region hint such as US or JP.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub region: Option<String>,
508    /// Optional provider safe-search preference.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub safe_search: Option<WebSearchSafeSearch>,
511}
512
513/// One normalized search result returned by a provider.
514#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
515pub struct WebSearchResultItem {
516    /// Result title shown to the user.
517    pub title: String,
518    /// Canonical result URL when the provider supplies one.
519    pub url: String,
520    /// Short provider snippet or summary.
521    #[serde(default)]
522    pub snippet: String,
523    /// Optional provider or source label.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub source: Option<String>,
526    /// Optional publication timestamp or date string.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub published_at: Option<String>,
529}
530
531/// Normalized response returned by a web-search provider.
532#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
533pub struct WebSearchResponse {
534    /// Whether a provider was available for this request.
535    pub available: bool,
536    /// Provider name or fixture label.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub provider: Option<String>,
539    /// Search results after provider normalization.
540    #[serde(default)]
541    pub results: Vec<WebSearchResultItem>,
542    /// Whether returned results were truncated by policy or provider limits.
543    #[serde(default)]
544    pub truncated: bool,
545    /// Optional unavailable or partial-result message.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub message: Option<String>,
548}
549
550/// Host bridge for provider-neutral public web search.
551#[async_trait]
552pub trait WebSearchProvider: Send + Sync {
553    /// Return whether this provider can serve search requests now.
554    fn is_available(&self) -> bool {
555        true
556    }
557
558    /// Search with a bounded provider-neutral request.
559    async fn search(&self, request: WebSearchRequest) -> WebSearchResponse;
560}
561
562/// Web-search provider used when no host installed search.
563#[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/// Static web-search provider used by tests and eval fixtures.
583#[derive(Debug, Clone, Default)]
584pub struct StaticWebSearchProvider {
585    responses: HashMap<String, WebSearchResponse>,
586    available: bool,
587}
588
589impl StaticWebSearchProvider {
590    /// Create a deterministic provider from exact-query responses.
591    pub fn new(responses: HashMap<String, WebSearchResponse>) -> Self {
592        Self {
593            responses,
594            available: true,
595        }
596    }
597
598    /// Create a deterministic provider with explicit availability.
599    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
657/// Shared slot used by `web_search` to find the active host provider.
658pub 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/// Request sent to a host command runner.
672#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
673pub struct CommandRequest {
674    /// Full argv vector, including executable name.
675    pub argv: Vec<String>,
676    /// Working directory for process execution.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub cwd: Option<String>,
679    /// Environment variables explicitly supplied by policy.
680    #[serde(default)]
681    pub env: HashMap<String, String>,
682    /// Max runtime in milliseconds.
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub timeout_ms: Option<u64>,
685    /// Max combined output characters.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub max_output_chars: Option<usize>,
688    /// User-visible reason for the command.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub reason: Option<String>,
691}
692
693/// Result returned by a host command runner.
694#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
695pub struct CommandResponse {
696    /// Whether the process completed with exit code 0.
697    pub success: bool,
698    /// Process exit code when available.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub exit_code: Option<i32>,
701    /// Termination reason: exited, timeout, cancelled, unavailable, or error.
702    pub termination: String,
703    /// Captured stdout after truncation.
704    #[serde(default)]
705    pub stdout: String,
706    /// Captured stderr after truncation.
707    #[serde(default)]
708    pub stderr: String,
709    /// Captured stdout and stderr after truncation.
710    #[serde(default)]
711    pub combined_output: String,
712    /// True when output was truncated.
713    #[serde(default)]
714    pub truncated: bool,
715    /// True when the timeout ended the command.
716    #[serde(default)]
717    pub timed_out: bool,
718    /// Working directory used for execution.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub cwd: Option<String>,
721    /// Redacted argv evidence.
722    #[serde(default)]
723    pub argv_redacted: Vec<String>,
724}
725
726/// Host bridge for controlled command execution.
727#[async_trait]
728pub trait CommandRunner: Send + Sync {
729    /// Return whether this runner can execute commands now.
730    fn is_available(&self) -> bool {
731        true
732    }
733
734    /// Run a command request and return bounded output evidence.
735    async fn run_command(
736        &self,
737        request: CommandRequest,
738        ctx: ai_agents_core::ToolExecutionContext,
739    ) -> CommandResponse;
740}
741
742/// Command runner used when no host installed process execution.
743#[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/// Static command runner used by tests and eval fixtures.
768#[derive(Debug, Clone, Default)]
769pub struct StaticCommandRunner {
770    responses: HashMap<Vec<String>, CommandResponse>,
771    available: bool,
772}
773
774impl StaticCommandRunner {
775    /// Create a deterministic runner from exact argv responses.
776    pub fn new(responses: HashMap<Vec<String>, CommandResponse>) -> Self {
777        Self {
778            responses,
779            available: true,
780        }
781    }
782
783    /// Create a deterministic runner with explicit availability.
784    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/// Process-backed command runner for CLI and trusted hosts.
832#[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
938/// Shared slot used by `command` to find the active host runner.
939pub 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/// Status value for one session-local todo item.
1036#[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/// One structured task tracked by the session-local todo tool.
1047#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1048pub struct TodoItem {
1049    /// Stable task ID chosen by the model or host.
1050    pub id: String,
1051    /// Task description shown in summaries and UIs.
1052    pub content: String,
1053    /// Present-continuous phrase for active status displays.
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub active_form: Option<String>,
1056    /// Current task state.
1057    #[serde(default)]
1058    pub status: TodoStatus,
1059}
1060
1061/// Session-local todo storage shared by the registry and runtime accessor.
1062#[derive(Clone, Default)]
1063pub struct TodoStore {
1064    inner: Arc<RwLock<Vec<TodoItem>>>,
1065}
1066
1067impl TodoStore {
1068    /// Return a snapshot of all todo items.
1069    pub fn list(&self) -> Vec<TodoItem> {
1070        self.inner.read().clone()
1071    }
1072
1073    /// Replace the full todo list.
1074    pub fn set(&self, items: Vec<TodoItem>) {
1075        *self.inner.write() = items;
1076    }
1077
1078    /// Update one item by ID and return whether it existed.
1079    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    /// Remove every todo item.
1103    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}