use async_trait::async_trait;
use parking_lot::RwLock;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Digest;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolProviderType {
#[default]
Builtin,
Yaml,
Process,
Mcp,
Wasm,
Http,
Custom,
}
impl ToolProviderType {
pub fn default_trust_level(&self) -> TrustLevel {
match self {
ToolProviderType::Builtin => TrustLevel::Full,
ToolProviderType::Yaml => TrustLevel::High,
ToolProviderType::Process => TrustLevel::Medium,
ToolProviderType::Mcp => TrustLevel::Medium,
ToolProviderType::Custom => TrustLevel::Medium,
ToolProviderType::Wasm => TrustLevel::Sandboxed,
ToolProviderType::Http => TrustLevel::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Low,
Sandboxed,
#[default]
Medium,
High,
Full,
}
impl PartialOrd for TrustLevel {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TrustLevel {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_u8().cmp(&other.as_u8())
}
}
impl TrustLevel {
fn as_u8(&self) -> u8 {
match self {
TrustLevel::Low => 0,
TrustLevel::Sandboxed => 1,
TrustLevel::Medium => 2,
TrustLevel::High => 3,
TrustLevel::Full => 4,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolAliases {
#[serde(default)]
pub names: HashMap<String, String>,
#[serde(default)]
pub descriptions: HashMap<String, String>,
#[serde(default)]
pub parameter_aliases: HashMap<String, HashMap<String, String>>,
}
impl ToolAliases {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, lang: impl Into<String>, name: impl Into<String>) -> Self {
self.names.insert(lang.into(), name.into());
self
}
pub fn with_description(mut self, lang: impl Into<String>, desc: impl Into<String>) -> Self {
self.descriptions.insert(lang.into(), desc.into());
self
}
pub fn get_name(&self, lang: &str) -> Option<&str> {
self.names.get(lang).map(|s| s.as_str())
}
pub fn get_description(&self, lang: &str) -> Option<&str> {
self.descriptions.get(lang).map(|s| s.as_str())
}
pub fn is_empty(&self) -> bool {
self.names.is_empty() && self.descriptions.is_empty() && self.parameter_aliases.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolMetadata {
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_duration_ms: Option<u64>,
#[serde(default)]
pub has_side_effects: bool,
#[serde(default)]
pub requires_network: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom: HashMap<String, Value>,
}
impl ToolMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_side_effects(mut self) -> Self {
self.has_side_effects = true;
self
}
pub fn with_network(mut self) -> Self {
self.requires_network = true;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolContext {
pub session_id: Option<String>,
pub user_id: Option<String>,
pub state_name: Option<String>,
pub language: Option<String>,
pub extra: HashMap<String, Value>,
}
impl ToolContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn with_state(mut self, state_name: impl Into<String>) -> Self {
self.state_name = Some(state_name.into());
self
}
pub fn with_language(mut self, language: impl Into<String>) -> Self {
self.language = Some(language.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct FileVersionEvidence {
pub path: String,
pub sha256: String,
pub size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified_unix_ms: Option<u128>,
}
#[derive(Clone, Default)]
pub struct FileVersionStore {
inner: Arc<RwLock<HashMap<String, FileVersionEvidence>>>,
}
impl FileVersionStore {
pub fn record(&self, evidence: FileVersionEvidence) {
self.inner.write().insert(evidence.path.clone(), evidence);
}
pub fn get(&self, path: impl AsRef<Path>) -> Option<FileVersionEvidence> {
let key = normalize_version_path(path.as_ref());
self.inner.read().get(&key).cloned()
}
pub fn matches(&self, evidence: &FileVersionEvidence) -> bool {
self.inner
.read()
.get(&evidence.path)
.is_some_and(|stored| stored == evidence)
}
}
pub fn file_version_evidence(
path: impl AsRef<Path>,
bytes: &[u8],
) -> std::io::Result<FileVersionEvidence> {
let path = path.as_ref();
let metadata = std::fs::metadata(path)?;
let modified_unix_ms = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis());
let mut hasher = sha2::Sha256::new();
sha2::Digest::update(&mut hasher, bytes);
let hash = sha2::Digest::finalize(hasher);
Ok(FileVersionEvidence {
path: normalize_version_path(path),
sha256: format!("{:x}", hash),
size_bytes: metadata.len(),
modified_unix_ms,
})
}
fn normalize_version_path(path: &Path) -> String {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
path.components()
.collect::<PathBuf>()
.to_string_lossy()
.to_string()
}
pub type QuestionHandlerSlot = Arc<RwLock<Option<Arc<dyn QuestionHandler>>>>;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct QuestionRequest {
pub question: String,
#[serde(default)]
pub options: Vec<String>,
#[serde(default)]
pub multi_select: bool,
#[serde(default = "default_true")]
pub allow_other: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct QuestionResponse {
pub answered: bool,
#[serde(default)]
pub selected: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub other_text: Option<String>,
#[serde(default)]
pub timed_out: bool,
#[serde(default)]
pub unavailable: bool,
}
#[async_trait]
pub trait QuestionHandler: Send + Sync {
async fn ask_question(&self, request: QuestionRequest) -> QuestionResponse;
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity {
Error,
Warning,
Info,
Hint,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct DiagnosticsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<DiagnosticSeverity>,
#[serde(default)]
pub max_results: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DiagnosticItem {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
pub severity: DiagnosticSeverity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct DiagnosticsResponse {
pub available: bool,
#[serde(default)]
pub diagnostics: Vec<DiagnosticItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[async_trait]
pub trait DiagnosticsProvider: Send + Sync {
fn is_available(&self) -> bool {
true
}
async fn diagnostics(&self, request: DiagnosticsRequest) -> DiagnosticsResponse;
}
#[derive(Debug, Default)]
pub struct UnavailableDiagnosticsProvider;
#[async_trait]
impl DiagnosticsProvider for UnavailableDiagnosticsProvider {
fn is_available(&self) -> bool {
false
}
async fn diagnostics(&self, _request: DiagnosticsRequest) -> DiagnosticsResponse {
DiagnosticsResponse {
available: false,
diagnostics: Vec::new(),
message: Some("diagnostics provider is unavailable".to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct StaticDiagnosticsProvider {
diagnostics: Vec<DiagnosticItem>,
available: bool,
}
impl StaticDiagnosticsProvider {
pub fn new(diagnostics: Vec<DiagnosticItem>) -> Self {
Self {
diagnostics,
available: true,
}
}
pub fn with_availability(diagnostics: Vec<DiagnosticItem>, available: bool) -> Self {
Self {
diagnostics,
available,
}
}
}
#[async_trait]
impl DiagnosticsProvider for StaticDiagnosticsProvider {
fn is_available(&self) -> bool {
self.available
}
async fn diagnostics(&self, request: DiagnosticsRequest) -> DiagnosticsResponse {
if !self.available {
return DiagnosticsResponse {
available: false,
diagnostics: Vec::new(),
message: Some("diagnostics provider is unavailable".to_string()),
};
}
let mut diagnostics: Vec<DiagnosticItem> = self
.diagnostics
.iter()
.filter(|item| {
request
.path
.as_ref()
.is_none_or(|path| item.path.starts_with(path))
})
.filter(|item| {
request
.severity
.as_ref()
.is_none_or(|severity| &item.severity == severity)
})
.cloned()
.collect();
if let Some(max_results) = request.max_results {
diagnostics.truncate(max_results);
}
DiagnosticsResponse {
available: true,
diagnostics,
message: None,
}
}
}
pub type DiagnosticsProviderSlot = Arc<RwLock<Arc<dyn DiagnosticsProvider>>>;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum WebSearchSafeSearch {
Off,
#[default]
Moderate,
Strict,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
pub struct WebSearchRequest {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_results: Option<usize>,
#[serde(default)]
pub include_domains: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub safe_search: Option<WebSearchSafeSearch>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
pub struct WebSearchResultItem {
pub title: String,
pub url: String,
#[serde(default)]
pub snippet: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct WebSearchResponse {
pub available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default)]
pub results: Vec<WebSearchResultItem>,
#[serde(default)]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[async_trait]
pub trait WebSearchProvider: Send + Sync {
fn is_available(&self) -> bool {
true
}
async fn search(&self, request: WebSearchRequest) -> WebSearchResponse;
}
#[derive(Debug, Default)]
pub struct UnavailableWebSearchProvider;
#[async_trait]
impl WebSearchProvider for UnavailableWebSearchProvider {
fn is_available(&self) -> bool {
false
}
async fn search(&self, _request: WebSearchRequest) -> WebSearchResponse {
WebSearchResponse {
available: false,
results: Vec::new(),
message: Some("web search provider is unavailable".to_string()),
..WebSearchResponse::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StaticWebSearchProvider {
responses: HashMap<String, WebSearchResponse>,
available: bool,
}
impl StaticWebSearchProvider {
pub fn new(responses: HashMap<String, WebSearchResponse>) -> Self {
Self {
responses,
available: true,
}
}
pub fn with_availability(
responses: HashMap<String, WebSearchResponse>,
available: bool,
) -> Self {
Self {
responses,
available,
}
}
}
#[async_trait]
impl WebSearchProvider for StaticWebSearchProvider {
fn is_available(&self) -> bool {
self.available
}
async fn search(&self, request: WebSearchRequest) -> WebSearchResponse {
if !self.available {
return WebSearchResponse {
available: false,
message: Some("web search provider is unavailable".to_string()),
..WebSearchResponse::default()
};
}
let mut response = self
.responses
.get(&request.query)
.cloned()
.unwrap_or_else(|| WebSearchResponse {
available: true,
provider: Some("static".to_string()),
results: Vec::new(),
message: Some("no fixture search results matched the query".to_string()),
..WebSearchResponse::default()
});
response.available = true;
if response.provider.is_none() {
response.provider = Some("static".to_string());
}
if !request.include_domains.is_empty() {
let before = response.results.len();
response
.results
.retain(|item| result_matches_domains(&item.url, &request.include_domains));
response.truncated |= response.results.len() != before;
}
if let Some(max_results) = request.max_results
&& response.results.len() > max_results
{
response.results.truncate(max_results);
response.truncated = true;
}
response
}
}
pub type WebSearchProviderSlot = Arc<RwLock<Arc<dyn WebSearchProvider>>>;
fn result_matches_domains(url: &str, domains: &[String]) -> bool {
let host = reqwest::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase))
.unwrap_or_else(|| url.to_ascii_lowercase());
domains.iter().any(|domain| {
let domain = domain.trim().to_ascii_lowercase();
host == domain || host.ends_with(&format!(".{}", domain))
})
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct CommandRequest {
pub argv: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_chars: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct CommandResponse {
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub termination: String,
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
#[serde(default)]
pub combined_output: String,
#[serde(default)]
pub truncated: bool,
#[serde(default)]
pub timed_out: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub argv_redacted: Vec<String>,
}
#[async_trait]
pub trait CommandRunner: Send + Sync {
fn is_available(&self) -> bool {
true
}
async fn run_command(
&self,
request: CommandRequest,
ctx: ai_agents_core::ToolExecutionContext,
) -> CommandResponse;
}
#[derive(Debug, Default)]
pub struct UnavailableCommandRunner;
#[async_trait]
impl CommandRunner for UnavailableCommandRunner {
fn is_available(&self) -> bool {
false
}
async fn run_command(
&self,
request: CommandRequest,
_ctx: ai_agents_core::ToolExecutionContext,
) -> CommandResponse {
CommandResponse {
success: false,
termination: "unavailable".to_string(),
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StaticCommandRunner {
responses: HashMap<Vec<String>, CommandResponse>,
available: bool,
}
impl StaticCommandRunner {
pub fn new(responses: HashMap<Vec<String>, CommandResponse>) -> Self {
Self {
responses,
available: true,
}
}
pub fn with_availability(
responses: HashMap<Vec<String>, CommandResponse>,
available: bool,
) -> Self {
Self {
responses,
available,
}
}
}
#[async_trait]
impl CommandRunner for StaticCommandRunner {
fn is_available(&self) -> bool {
self.available
}
async fn run_command(
&self,
request: CommandRequest,
_ctx: ai_agents_core::ToolExecutionContext,
) -> CommandResponse {
if !self.available {
return CommandResponse {
success: false,
termination: "unavailable".to_string(),
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
};
}
self.responses
.get(&request.argv)
.cloned()
.unwrap_or_else(|| CommandResponse {
success: false,
exit_code: Some(127),
termination: "not_found".to_string(),
stderr: "mock command not found".to_string(),
combined_output: "mock command not found".to_string(),
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
})
}
}
#[derive(Debug, Clone, Default)]
pub struct ProcessCommandRunner;
#[async_trait]
impl CommandRunner for ProcessCommandRunner {
async fn run_command(
&self,
request: CommandRequest,
ctx: ai_agents_core::ToolExecutionContext,
) -> CommandResponse {
if request.argv.is_empty() {
return CommandResponse {
success: false,
termination: "error".to_string(),
stderr: "argv must not be empty".to_string(),
combined_output: "argv must not be empty".to_string(),
cwd: request.cwd,
argv_redacted: Vec::new(),
..CommandResponse::default()
};
}
let mut command = tokio::process::Command::new(&request.argv[0]);
command.args(&request.argv[1..]);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear();
command.kill_on_drop(true);
if let Some(cwd) = &request.cwd {
command.current_dir(cwd);
}
for (key, value) in &request.env {
command.env(key, value);
}
let timeout_ms = request
.timeout_ms
.or(ctx.limits.timeout_ms)
.unwrap_or(30_000);
let max_output_chars = request
.max_output_chars
.or(ctx.limits.max_output_chars)
.unwrap_or(20_000);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
return CommandResponse {
success: false,
termination: "error".to_string(),
stderr: error.to_string(),
combined_output: error.to_string(),
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
};
}
};
let output_byte_cap = max_output_chars.saturating_mul(4).max(1);
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_task = tokio::spawn(read_pipe_bounded(stdout, output_byte_cap));
let stderr_task = tokio::spawn(read_pipe_bounded(stderr, output_byte_cap));
let status = tokio::time::timeout(Duration::from_millis(timeout_ms), child.wait()).await;
match status {
Ok(Ok(status)) => {
let (stdout, stdout_truncated) = stdout_task.await.unwrap_or_default();
let (stderr, stderr_truncated) = stderr_task.await.unwrap_or_default();
command_output_response(
status,
stdout,
stderr,
stdout_truncated || stderr_truncated,
request,
max_output_chars,
)
}
Ok(Err(error)) => CommandResponse {
success: false,
termination: "error".to_string(),
stderr: error.to_string(),
combined_output: error.to_string(),
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
},
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
stdout_task.abort();
stderr_task.abort();
let message = "command timed out; process cleanup requested".to_string();
CommandResponse {
success: false,
termination: "timeout".to_string(),
stderr: message.clone(),
combined_output: message,
timed_out: true,
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
..CommandResponse::default()
}
}
}
}
}
pub type CommandRunnerSlot = Arc<RwLock<Arc<dyn CommandRunner>>>;
async fn read_pipe_bounded<R>(pipe: Option<R>, max_bytes: usize) -> (Vec<u8>, bool)
where
R: tokio::io::AsyncRead + Unpin,
{
let Some(mut pipe) = pipe else {
return (Vec::new(), false);
};
let mut output = Vec::new();
let mut truncated = false;
let mut buffer = vec![0u8; 8192];
loop {
let read = match pipe.read(&mut buffer).await {
Ok(0) => break,
Ok(read) => read,
Err(_) => break,
};
let remaining = max_bytes.saturating_sub(output.len());
if remaining > 0 {
output.extend_from_slice(&buffer[..read.min(remaining)]);
}
if read > remaining {
truncated = true;
}
}
(output, truncated)
}
fn command_output_response(
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
pre_truncated: bool,
request: CommandRequest,
max_output_chars: usize,
) -> CommandResponse {
let stdout = String::from_utf8_lossy(&stdout).to_string();
let stderr = String::from_utf8_lossy(&stderr).to_string();
let combined = if stderr.is_empty() {
stdout.clone()
} else if stdout.is_empty() {
stderr.clone()
} else {
format!("{}\n{}", stdout, stderr)
};
let (stdout, stdout_truncated) = truncate_chars(stdout, max_output_chars);
let (stderr, stderr_truncated) = truncate_chars(stderr, max_output_chars);
let (combined_output, combined_truncated) = truncate_chars(combined, max_output_chars);
CommandResponse {
success: status.success(),
exit_code: status.code(),
termination: "exited".to_string(),
stdout,
stderr,
combined_output,
truncated: pre_truncated || stdout_truncated || stderr_truncated || combined_truncated,
timed_out: false,
cwd: request.cwd,
argv_redacted: redact_argv(&request.argv),
}
}
fn truncate_chars(value: String, max_chars: usize) -> (String, bool) {
let mut chars = value.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
(truncated, true)
} else {
(value, false)
}
}
fn redact_argv(argv: &[String]) -> Vec<String> {
let mut redacted = Vec::with_capacity(argv.len());
let mut redact_next = false;
for arg in argv {
let lower = arg.to_ascii_lowercase();
let sensitive = lower.contains("token")
|| lower.contains("secret")
|| lower.contains("password")
|| lower.contains("apikey")
|| lower.contains("api-key");
if redact_next || sensitive {
redacted.push("[redacted]".to_string());
} else {
redacted.push(arg.clone());
}
redact_next = matches!(
lower.as_str(),
"--token" | "--secret" | "--password" | "--api-key"
);
}
redacted
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
#[default]
Pending,
InProgress,
Completed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TodoItem {
pub id: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_form: Option<String>,
#[serde(default)]
pub status: TodoStatus,
}
#[derive(Clone, Default)]
pub struct TodoStore {
inner: Arc<RwLock<Vec<TodoItem>>>,
}
impl TodoStore {
pub fn list(&self) -> Vec<TodoItem> {
self.inner.read().clone()
}
pub fn set(&self, items: Vec<TodoItem>) {
*self.inner.write() = items;
}
pub fn update(
&self,
id: &str,
content: Option<String>,
active_form: Option<String>,
status: Option<TodoStatus>,
) -> bool {
let mut items = self.inner.write();
let Some(item) = items.iter_mut().find(|item| item.id == id) else {
return false;
};
if let Some(content) = content {
item.content = content;
}
if let Some(active_form) = active_form {
item.active_form = Some(active_form);
}
if let Some(status) = status {
item.status = status;
}
true
}
pub fn clear(&self) {
self.inner.write().clear();
}
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_command_runner_timeout_helper() {
if std::env::var("AI_AGENTS_PROCESS_TIMEOUT_HELPER").as_deref() != Ok("1") {
return;
}
let Ok(started_path) = std::env::var("AI_AGENTS_PROCESS_TIMEOUT_STARTED") else {
return;
};
let Ok(completed_path) = std::env::var("AI_AGENTS_PROCESS_TIMEOUT_COMPLETED") else {
return;
};
std::fs::write(started_path, b"started").unwrap();
std::thread::sleep(Duration::from_secs(10));
std::fs::write(completed_path, b"completed").unwrap();
}
#[tokio::test]
async fn process_command_runner_timeout_kills_direct_child() {
let directory = tempfile::tempdir().unwrap();
let started_path = directory.path().join("started");
let completed_path = directory.path().join("completed");
let executable = std::env::current_exe().unwrap();
let request = CommandRequest {
argv: vec![
executable.to_string_lossy().into_owned(),
"types::tests::process_command_runner_timeout_helper".to_string(),
"--exact".to_string(),
],
env: HashMap::from([
(
"AI_AGENTS_PROCESS_TIMEOUT_HELPER".to_string(),
"1".to_string(),
),
(
"AI_AGENTS_PROCESS_TIMEOUT_STARTED".to_string(),
started_path.to_string_lossy().into_owned(),
),
(
"AI_AGENTS_PROCESS_TIMEOUT_COMPLETED".to_string(),
completed_path.to_string_lossy().into_owned(),
),
]),
timeout_ms: Some(2_000),
..CommandRequest::default()
};
let response = ProcessCommandRunner
.run_command(
request,
ai_agents_core::ToolExecutionContext::test("command"),
)
.await;
assert_eq!(response.termination, "timeout");
assert!(response.timed_out);
assert!(started_path.exists());
assert!(!completed_path.exists());
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(!completed_path.exists());
}
#[test]
fn test_provider_type_default() {
let pt = ToolProviderType::default();
assert_eq!(pt, ToolProviderType::Builtin);
}
#[test]
fn test_provider_type_trust_levels() {
assert_eq!(
ToolProviderType::Builtin.default_trust_level(),
TrustLevel::Full
);
assert_eq!(
ToolProviderType::Yaml.default_trust_level(),
TrustLevel::High
);
assert_eq!(
ToolProviderType::Wasm.default_trust_level(),
TrustLevel::Sandboxed
);
assert_eq!(
ToolProviderType::Http.default_trust_level(),
TrustLevel::Low
);
}
#[test]
fn test_trust_level_ordering() {
assert!(TrustLevel::Full > TrustLevel::High);
assert!(TrustLevel::High > TrustLevel::Medium);
assert!(TrustLevel::Medium > TrustLevel::Sandboxed);
assert!(TrustLevel::Sandboxed > TrustLevel::Low);
}
#[test]
fn test_tool_aliases() {
let aliases = ToolAliases::new()
.with_name("ko", "웹검색")
.with_name("ja", "ウェブ検索")
.with_description("ko", "웹에서 정보 검색");
assert_eq!(aliases.get_name("ko"), Some("웹검색"));
assert_eq!(aliases.get_name("ja"), Some("ウェブ検索"));
assert_eq!(aliases.get_name("en"), None);
assert_eq!(aliases.get_description("ko"), Some("웹에서 정보 검색"));
assert!(!aliases.is_empty());
}
#[test]
fn test_tool_metadata() {
let metadata = ToolMetadata::new()
.with_tags(vec!["network".to_string(), "api".to_string()])
.with_side_effects()
.with_network();
assert_eq!(metadata.tags.len(), 2);
assert!(metadata.has_side_effects);
assert!(metadata.requires_network);
}
#[test]
fn test_tool_context() {
let ctx = ToolContext::new()
.with_session("session123")
.with_user("user456")
.with_state("greeting")
.with_language("ko");
assert_eq!(ctx.session_id, Some("session123".to_string()));
assert_eq!(ctx.user_id, Some("user456".to_string()));
assert_eq!(ctx.state_name, Some("greeting".to_string()));
assert_eq!(ctx.language, Some("ko".to_string()));
}
#[test]
fn test_provider_type_serde() {
let json = serde_json::to_string(&ToolProviderType::Builtin).unwrap();
assert_eq!(json, "\"builtin\"");
let pt: ToolProviderType = serde_json::from_str("\"yaml\"").unwrap();
assert_eq!(pt, ToolProviderType::Yaml);
}
}