1use crate::builtins::{Builtin, Extension};
64use crate::error::Error;
65use crate::{Bash, ExecResult, ExecutionLimits, OutputCallback};
66use async_trait::async_trait;
67use futures_core::Stream;
68use serde::{Deserialize, Serialize};
69use std::collections::HashSet;
70use std::future::Future;
71use std::path::PathBuf;
72use std::pin::Pin;
73use std::sync::{Arc, Mutex};
74use std::task::{Context, Poll};
75use std::time::Duration;
76type ToolExecutionFuture = Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send>>;
77type ToolExecutionRunner = Box<
78 dyn FnOnce(Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>) -> ToolExecutionFuture
79 + Send,
80>;
81
82pub const VERSION: &str = env!("CARGO_PKG_VERSION");
84
85pub type ToolService =
87 tower::util::BoxCloneService<serde_json::Value, serde_json::Value, ToolError>;
88
89#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
95pub enum ToolError {
96 #[error("{0}")]
98 UserFacing(String),
99 #[error("{0}")]
101 Internal(String),
102}
103
104impl ToolError {
105 pub fn is_user_facing(&self) -> bool {
107 matches!(self, Self::UserFacing(_))
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct ToolImage {
117 pub base64: String,
118 pub media_type: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct ToolOutputMetadata {
127 #[serde(with = "duration_millis")]
128 pub duration: Duration,
129 pub extra: serde_json::Value,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct ToolOutput {
138 pub result: serde_json::Value,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub images: Vec<ToolImage>,
141 pub metadata: ToolOutputMetadata,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct ToolOutputChunk {
150 pub data: serde_json::Value,
151 pub kind: String,
152}
153
154pub struct ToolOutputStream {
159 receiver: tokio::sync::mpsc::UnboundedReceiver<ToolOutputChunk>,
160}
161
162impl Stream for ToolOutputStream {
163 type Item = ToolOutputChunk;
164
165 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
166 self.receiver.poll_recv(cx)
167 }
168}
169
170#[derive(Default)]
171struct ToolExecutionStreamState {
172 sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
173 receiver: Option<tokio::sync::mpsc::UnboundedReceiver<ToolOutputChunk>>,
174}
175
176pub struct ToolExecution {
181 runner: Option<ToolExecutionRunner>,
182 stream_state: Arc<Mutex<ToolExecutionStreamState>>,
183}
184
185impl ToolExecution {
186 pub(crate) fn new<F, Fut>(runner: F) -> Self
187 where
188 F: FnOnce(Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>) -> Fut
189 + Send
190 + 'static,
191 Fut: Future<Output = Result<ToolOutput, ToolError>> + Send + 'static,
192 {
193 Self {
194 runner: Some(Box::new(move |sender| Box::pin(runner(sender)))),
195 stream_state: Arc::new(Mutex::new(ToolExecutionStreamState::default())),
196 }
197 }
198
199 pub fn output_stream(&self) -> Option<ToolOutputStream> {
201 let mut state = match self.stream_state.lock() {
202 Ok(state) => state,
203 Err(poisoned) => poisoned.into_inner(),
204 };
205 if state.receiver.is_none() {
206 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
207 state.sender = Some(sender);
208 state.receiver = Some(receiver);
209 }
210 state
211 .receiver
212 .take()
213 .map(|receiver| ToolOutputStream { receiver })
214 }
215
216 pub async fn execute(mut self) -> Result<ToolOutput, ToolError> {
218 let sender = match self.stream_state.lock() {
219 Ok(state) => state.sender.clone(),
220 Err(poisoned) => poisoned.into_inner().sender.clone(),
221 };
222 let Some(runner) = self.runner.take() else {
223 return Err(ToolError::Internal(
224 "tool execution may only be run once".to_string(),
225 ));
226 };
227 runner(sender).await
228 }
229}
230
231mod duration_millis {
232 use serde::{Deserialize, Deserializer, Serializer};
233 use std::time::Duration;
234
235 pub fn serialize<S>(value: &Duration, serializer: S) -> Result<S::Ok, S::Error>
236 where
237 S: Serializer,
238 {
239 serializer.serialize_u64(value.as_millis() as u64)
240 }
241
242 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
243 where
244 D: Deserializer<'de>,
245 {
246 let millis = u64::deserialize(deserializer)?;
247 Ok(Duration::from_millis(millis))
248 }
249}
250
251#[cfg(feature = "jq")]
253const BUILTINS: &str = "\
254echo printf cat read \
255grep sed awk jq head tail sort uniq cut tr wc nl paste column comm diff strings tac rev \
256cd pwd ls find mkdir mktemp rm rmdir cp mv touch chmod chown ln \
257file stat less tar gzip gunzip du df \
258test [ true false exit return break continue \
259export set unset local shift source eval declare typeset readonly shopt getopts \
260sleep date seq expr yes wait timeout xargs tee watch \
261basename dirname realpath \
262pushd popd dirs \
263whoami hostname uname id env printenv history \
264curl wget \
265od xxd hexdump base64 \
266kill";
267
268#[cfg(not(feature = "jq"))]
269const BUILTINS: &str = "\
270echo printf cat read \
271grep sed awk head tail sort uniq cut tr wc nl paste column comm diff strings tac rev \
272cd pwd ls find mkdir mktemp rm rmdir cp mv touch chmod chown ln \
273file stat less tar gzip gunzip du df \
274test [ true false exit return break continue \
275export set unset local shift source eval declare typeset readonly shopt getopts \
276sleep date seq expr yes wait timeout xargs tee watch \
277basename dirname realpath \
278pushd popd dirs \
279whoami hostname uname id env printenv history \
280curl wget \
281od xxd hexdump base64 \
282kill";
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ToolRequest {
287 pub commands: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub timeout_ms: Option<u64>,
294}
295
296impl ToolRequest {
297 pub fn new(commands: impl Into<String>) -> Self {
299 Self {
300 commands: commands.into(),
301 timeout_ms: None,
302 }
303 }
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct ToolResponse {
309 pub stdout: String,
311 pub stderr: String,
313 pub exit_code: i32,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub error: Option<String>,
318 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
320 pub stdout_truncated: bool,
321 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
323 pub stderr_truncated: bool,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub final_env: Option<std::collections::HashMap<String, String>>,
327}
328
329impl From<ExecResult> for ToolResponse {
330 fn from(result: ExecResult) -> Self {
331 Self {
332 stdout: result.stdout,
333 stderr: result.stderr,
334 exit_code: result.exit_code,
335 error: None,
336 stdout_truncated: result.stdout_truncated,
337 stderr_truncated: result.stderr_truncated,
338 final_env: result.final_env,
339 }
340 }
341}
342
343pub(crate) fn tool_request_schema() -> serde_json::Value {
345 serde_json::json!({
346 "type": "object",
347 "required": ["commands"],
348 "properties": {
349 "commands": {
350 "type": "string",
351 "description": "Bash commands to execute"
352 },
353 "timeout_ms": {
354 "type": ["integer", "null"],
355 "format": "uint64",
356 "minimum": 0,
357 "description": "Optional per-call timeout in milliseconds"
358 }
359 }
360 })
361}
362
363pub(crate) fn tool_response_schema() -> serde_json::Value {
365 serde_json::json!({
366 "type": "object",
367 "required": ["stdout", "stderr", "exit_code"],
368 "properties": {
369 "stdout": {
370 "type": "string",
371 "description": "Standard output from the script"
372 },
373 "stderr": {
374 "type": "string",
375 "description": "Standard error from the script"
376 },
377 "exit_code": {
378 "type": "integer",
379 "format": "int32",
380 "description": "Exit code; 0 means success"
381 },
382 "error": {
383 "type": ["string", "null"],
384 "description": "Error message if execution failed before running"
385 },
386 "stdout_truncated": {
387 "type": "boolean",
388 "default": false,
389 "description": "Whether stdout was truncated due to output size limits"
390 },
391 "stderr_truncated": {
392 "type": "boolean",
393 "default": false,
394 "description": "Whether stderr was truncated due to output size limits"
395 },
396 "final_env": {
397 "type": ["object", "null"],
398 "additionalProperties": { "type": "string" },
399 "description": "Final environment state when requested"
400 }
401 }
402 })
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ToolStatus {
408 pub phase: String,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub message: Option<String>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub percent_complete: Option<f32>,
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub eta_ms: Option<u64>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub output: Option<String>,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub stream: Option<String>,
425}
426
427impl ToolStatus {
428 pub fn new(phase: impl Into<String>) -> Self {
430 Self {
431 phase: phase.into(),
432 message: None,
433 percent_complete: None,
434 eta_ms: None,
435 output: None,
436 stream: None,
437 }
438 }
439
440 pub fn stdout(chunk: impl Into<String>) -> Self {
442 Self {
443 phase: "output".to_string(),
444 message: None,
445 percent_complete: None,
446 eta_ms: None,
447 output: Some(chunk.into()),
448 stream: Some("stdout".to_string()),
449 }
450 }
451
452 pub fn stderr(chunk: impl Into<String>) -> Self {
454 Self {
455 phase: "output".to_string(),
456 message: None,
457 percent_complete: None,
458 eta_ms: None,
459 output: Some(chunk.into()),
460 stream: Some("stderr".to_string()),
461 }
462 }
463
464 pub fn with_message(mut self, message: impl Into<String>) -> Self {
466 self.message = Some(message.into());
467 self
468 }
469
470 pub fn with_percent(mut self, percent: f32) -> Self {
472 self.percent_complete = Some(percent);
473 self
474 }
475
476 pub fn with_eta(mut self, eta_ms: u64) -> Self {
478 self.eta_ms = Some(eta_ms);
479 self
480 }
481}
482
483#[async_trait]
501pub trait Tool: Send + Sync {
502 fn name(&self) -> &str;
504
505 fn display_name(&self) -> &str;
507
508 fn short_description(&self) -> &str;
510
511 fn description(&self) -> &str;
513
514 fn help(&self) -> String;
516
517 fn system_prompt(&self) -> String;
519
520 fn locale(&self) -> &str;
522
523 fn input_schema(&self) -> serde_json::Value;
525
526 fn output_schema(&self) -> serde_json::Value;
528
529 fn version(&self) -> &str;
531
532 fn execution(&self, args: serde_json::Value) -> Result<ToolExecution, ToolError>;
534
535 async fn execute(&self, req: ToolRequest) -> ToolResponse;
537
538 async fn execute_with_status(
540 &self,
541 req: ToolRequest,
542 status_callback: Box<dyn FnMut(ToolStatus) + Send>,
543 ) -> ToolResponse;
544}
545
546type ToolConfigStep = Arc<dyn Fn(crate::BashBuilder) -> crate::BashBuilder + Send + Sync>;
554
555#[derive(Default)]
557pub struct BashToolBuilder {
558 locale: String,
560 username: Option<String>,
562 hostname: Option<String>,
564 limits: Option<ExecutionLimits>,
566 cwd: Option<PathBuf>,
568 env_vars: Vec<(String, String)>,
570 builtins: Vec<(String, Arc<dyn Builtin>)>,
572 config: Vec<ToolConfigStep>,
577}
578
579impl BashToolBuilder {
580 pub fn new() -> Self {
582 Self {
583 locale: "en-US".to_string(),
584 ..Self::default()
585 }
586 }
587
588 pub fn locale(mut self, locale: &str) -> Self {
590 self.locale = locale.to_string();
591 self
592 }
593
594 pub fn configure<F>(mut self, f: F) -> Self
608 where
609 F: Fn(crate::BashBuilder) -> crate::BashBuilder + Send + Sync + 'static,
610 {
611 self.config.push(Arc::new(f));
612 self
613 }
614
615 pub fn username(mut self, username: impl Into<String>) -> Self {
617 self.username = Some(username.into());
618 self
619 }
620
621 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
623 self.hostname = Some(hostname.into());
624 self
625 }
626
627 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
629 self.limits = Some(limits);
630 self
631 }
632
633 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
635 self.cwd = Some(cwd.into());
636 self
637 }
638
639 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
641 self.env_vars.push((key.into(), value.into()));
642 self
643 }
644
645 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
652 self.builtins.push((name.into(), Arc::from(builtin)));
653 self
654 }
655
656 pub fn extension<E>(mut self, extension: E) -> Self
663 where
664 E: Extension,
665 {
666 for (name, builtin) in extension.builtins() {
667 self.builtins.push((name, Arc::from(builtin)));
668 }
669 self
670 }
671
672 #[cfg(feature = "python")]
682 pub fn python(self) -> Self {
683 self.python_with_limits(crate::builtins::PythonLimits::default())
684 }
685
686 #[cfg(feature = "python")]
688 pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
689 use crate::builtins::Python;
690 self.builtin("python", Box::new(Python::with_limits(limits.clone())))
691 .builtin("python3", Box::new(Python::with_limits(limits)))
692 }
693
694 #[cfg(feature = "typescript")]
698 pub fn typescript(self) -> Self {
699 self.typescript_with_config(crate::builtins::TypeScriptConfig::default())
700 }
701
702 #[cfg(feature = "typescript")]
704 pub fn typescript_with_limits(self, limits: crate::builtins::TypeScriptLimits) -> Self {
705 self.extension(crate::builtins::TypeScriptExtension::with_limits(limits))
706 }
707
708 #[cfg(feature = "typescript")]
710 pub fn typescript_with_config(self, config: crate::builtins::TypeScriptConfig) -> Self {
711 self.extension(crate::builtins::TypeScriptExtension::with_config(config))
712 }
713
714 #[cfg(feature = "typescript")]
716 pub fn typescript_with_external_handler(
717 self,
718 limits: crate::builtins::TypeScriptLimits,
719 external_fns: Vec<String>,
720 handler: crate::builtins::TypeScriptExternalFnHandler,
721 ) -> Self {
722 self.extension(crate::builtins::TypeScriptExtension::with_external_handler(
723 limits,
724 external_fns,
725 handler,
726 ))
727 }
728
729 pub fn build(&self) -> BashTool {
731 let mut seen_builtin_names = HashSet::new();
732 let builtin_names: Vec<String> = self
733 .builtins
734 .iter()
735 .filter_map(|(name, _)| {
736 if seen_builtin_names.insert(name) {
737 Some(name.clone())
738 } else {
739 None
740 }
741 })
742 .collect();
743
744 let mut builtin_hints: Vec<String> = self
746 .builtins
747 .iter()
748 .filter_map(|(_, b)| b.llm_hint().map(String::from))
749 .collect();
750 builtin_hints.sort();
751 builtin_hints.dedup();
752
753 let locale = self.locale.clone();
754 let display_name = localized(locale.as_str(), "Bash", "Баш");
755
756 BashTool {
757 locale,
758 display_name: display_name.to_string(),
759 short_desc: localized(
760 self.locale.as_str(),
761 "Run bash commands in an isolated virtual filesystem",
762 "Виконує bash-команди в ізольованій віртуальній файловій системі",
763 )
764 .to_string(),
765 description: build_bash_description(self.locale.as_str(), &builtin_names),
766 username: self.username.clone(),
767 hostname: self.hostname.clone(),
768 limits: self.limits.clone(),
769 cwd: self.cwd.clone(),
770 env_vars: self.env_vars.clone(),
771 builtins: self.builtins.clone(),
772 config: self.config.clone(),
773 builtin_names,
774 builtin_hints,
775 }
776 }
777
778 pub fn build_service(&self) -> ToolService {
780 let tool = self.build();
781 tower::util::BoxCloneService::new(tower::service_fn(move |args| {
782 let tool = tool.clone();
783 async move {
784 let execution = tool.execution(args)?;
785 let output = execution.execute().await?;
786 Ok(output.result)
787 }
788 }))
789 }
790
791 pub fn build_tool_definition(&self) -> serde_json::Value {
793 let tool = self.build();
794 serde_json::json!({
795 "type": "function",
796 "function": {
797 "name": tool.name(),
798 "description": tool.description(),
799 "parameters": self.build_input_schema(),
800 }
801 })
802 }
803
804 pub fn build_input_schema(&self) -> serde_json::Value {
806 tool_request_schema()
807 }
808
809 pub fn build_output_schema(&self) -> serde_json::Value {
811 tool_response_schema()
812 }
813}
814
815#[derive(Clone)]
817pub struct BashTool {
818 locale: String,
819 display_name: String,
820 short_desc: String,
821 description: String,
822 username: Option<String>,
823 hostname: Option<String>,
824 limits: Option<ExecutionLimits>,
825 cwd: Option<PathBuf>,
826 env_vars: Vec<(String, String)>,
827 builtins: Vec<(String, Arc<dyn Builtin>)>,
828 config: Vec<ToolConfigStep>,
830 builtin_names: Vec<String>,
832 builtin_hints: Vec<String>,
834}
835
836impl BashTool {
837 pub fn builder() -> BashToolBuilder {
839 BashToolBuilder::new()
840 }
841
842 fn create_bash(&self) -> Bash {
847 let mut builder = Bash::builder();
848
849 if let Some(ref username) = self.username {
850 builder = builder.username(username);
851 }
852 if let Some(ref hostname) = self.hostname {
853 builder = builder.hostname(hostname);
854 }
855 if let Some(ref limits) = self.limits {
856 builder = builder.limits(limits.clone());
857 }
858 if let Some(ref cwd) = self.cwd {
859 builder = builder.cwd(cwd.clone());
860 }
861 for (key, value) in &self.env_vars {
862 builder = builder.env(key, value);
863 }
864 for (name, builtin) in &self.builtins {
866 builder = builder.builtin(name.clone(), Box::new(Arc::clone(builtin)));
867 }
868 for step in &self.config {
871 builder = step(builder);
872 }
873
874 builder.build()
875 }
876
877 fn build_help(&self) -> String {
879 build_bash_help(self)
880 }
881
882 fn language_warning(&self) -> Option<String> {
885 let mut missing = Vec::new();
886
887 let has_perl = self.builtin_names.iter().any(|n| n == "perl");
888 if !has_perl {
889 missing.push("perl");
890 }
891
892 let has_python = self
893 .builtin_names
894 .iter()
895 .any(|n| n == "python" || n == "python3");
896 if !has_python {
897 missing.push("python/python3");
898 }
899
900 if missing.is_empty() {
901 None
902 } else {
903 Some(format!("{} not available.", missing.join(", ")))
904 }
905 }
906
907 fn build_system_prompt(&self) -> String {
909 build_bash_system_prompt(self)
910 }
911
912 async fn run_request_with_stream(
913 &self,
914 req: ToolRequest,
915 stream_sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
916 ) -> ToolResponse {
917 if req.commands.is_empty() {
918 return ToolResponse {
919 stdout: String::new(),
920 stderr: String::new(),
921 exit_code: 0,
922 error: None,
923 ..Default::default()
924 };
925 }
926
927 let tool = self.clone();
928 let mut bash = tool.create_bash();
929
930 let fut = async {
931 let result = if let Some(sender) = stream_sender {
932 let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
933 if !stdout_chunk.is_empty() {
934 let _ = sender.send(ToolOutputChunk {
935 data: serde_json::json!(stdout_chunk),
936 kind: "stdout".to_string(),
937 });
938 }
939 if !stderr_chunk.is_empty() {
940 let _ = sender.send(ToolOutputChunk {
941 data: serde_json::json!(stderr_chunk),
942 kind: "stderr".to_string(),
943 });
944 }
945 });
946 bash.exec_streaming(&req.commands, output_cb).await
947 } else {
948 bash.exec(&req.commands).await
949 };
950
951 match result {
952 Ok(result) => result.into(),
953 Err(err) => ToolResponse {
954 stdout: String::new(),
955 stderr: err.to_string(),
956 exit_code: 1,
957 error: Some(error_kind(&err)),
958 ..Default::default()
959 },
960 }
961 };
962
963 if let Some(ms) = req.timeout_ms {
964 let duration = Duration::from_millis(ms);
965 match tokio::time::timeout(duration, fut).await {
966 Ok(response) => response,
967 Err(_) => timeout_response(duration),
968 }
969 } else {
970 fut.await
971 }
972 }
973}
974
975impl Default for BashTool {
976 fn default() -> Self {
977 BashToolBuilder::new().build()
978 }
979}
980
981#[async_trait]
982impl Tool for BashTool {
983 fn name(&self) -> &str {
984 "bashkit"
985 }
986
987 fn display_name(&self) -> &str {
988 &self.display_name
989 }
990
991 fn short_description(&self) -> &str {
992 &self.short_desc
993 }
994
995 fn description(&self) -> &str {
996 &self.description
997 }
998
999 fn help(&self) -> String {
1000 self.build_help()
1001 }
1002
1003 fn system_prompt(&self) -> String {
1004 self.build_system_prompt()
1005 }
1006
1007 fn locale(&self) -> &str {
1008 &self.locale
1009 }
1010
1011 fn input_schema(&self) -> serde_json::Value {
1012 tool_request_schema()
1013 }
1014
1015 fn output_schema(&self) -> serde_json::Value {
1016 tool_response_schema()
1017 }
1018
1019 fn version(&self) -> &str {
1020 VERSION
1021 }
1022
1023 fn execution(&self, args: serde_json::Value) -> Result<ToolExecution, ToolError> {
1024 let req = tool_request_from_value(self.locale(), args)?;
1025 let tool = self.clone();
1026
1027 Ok(ToolExecution::new(move |stream_sender| async move {
1028 let start = crate::time_compat::Instant::now();
1029 let response = tool.run_request_with_stream(req, stream_sender).await;
1030 tool_output_from_response(response, start.elapsed())
1031 }))
1032 }
1033
1034 async fn execute(&self, req: ToolRequest) -> ToolResponse {
1035 if req.commands.is_empty() {
1036 return ToolResponse {
1037 stdout: String::new(),
1038 stderr: String::new(),
1039 exit_code: 0,
1040 error: None,
1041 ..Default::default()
1042 };
1043 }
1044
1045 let mut bash = self.create_bash();
1046
1047 let fut = async {
1048 match bash.exec(&req.commands).await {
1049 Ok(result) => result.into(),
1050 Err(e) => ToolResponse {
1051 stdout: String::new(),
1052 stderr: e.to_string(),
1053 exit_code: 1,
1054 error: Some(error_kind(&e)),
1055 ..Default::default()
1056 },
1057 }
1058 };
1059
1060 if let Some(ms) = req.timeout_ms {
1061 let dur = Duration::from_millis(ms);
1062 match tokio::time::timeout(dur, fut).await {
1063 Ok(resp) => resp,
1064 Err(_elapsed) => timeout_response(dur),
1065 }
1066 } else {
1067 fut.await
1068 }
1069 }
1070
1071 async fn execute_with_status(
1072 &self,
1073 req: ToolRequest,
1074 mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
1075 ) -> ToolResponse {
1076 status_callback(ToolStatus::new("validate").with_percent(0.0));
1077
1078 if req.commands.is_empty() {
1079 status_callback(ToolStatus::new("complete").with_percent(100.0));
1080 return ToolResponse {
1081 stdout: String::new(),
1082 stderr: String::new(),
1083 exit_code: 0,
1084 error: None,
1085 ..Default::default()
1086 };
1087 }
1088
1089 status_callback(ToolStatus::new("parse").with_percent(10.0));
1090
1091 let mut bash = self.create_bash();
1092
1093 status_callback(ToolStatus::new("execute").with_percent(20.0));
1094
1095 let status_cb = Arc::new(Mutex::new(status_callback));
1097 let status_cb_output = status_cb.clone();
1098 let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
1099 if let Ok(mut cb) = status_cb_output.lock() {
1100 if !stdout_chunk.is_empty() {
1101 cb(ToolStatus::stdout(stdout_chunk));
1102 }
1103 if !stderr_chunk.is_empty() {
1104 cb(ToolStatus::stderr(stderr_chunk));
1105 }
1106 }
1107 });
1108
1109 let timeout_ms = req.timeout_ms;
1110
1111 let fut = async {
1112 let response = match bash.exec_streaming(&req.commands, output_cb).await {
1113 Ok(result) => result.into(),
1114 Err(e) => ToolResponse {
1115 stdout: String::new(),
1116 stderr: e.to_string(),
1117 exit_code: 1,
1118 error: Some(error_kind(&e)),
1119 ..Default::default()
1120 },
1121 };
1122
1123 if let Ok(mut cb) = status_cb.lock() {
1124 cb(ToolStatus::new("complete").with_percent(100.0));
1125 }
1126
1127 response
1128 };
1129
1130 if let Some(ms) = timeout_ms {
1131 let dur = Duration::from_millis(ms);
1132 match tokio::time::timeout(dur, fut).await {
1133 Ok(resp) => resp,
1134 Err(_elapsed) => timeout_response(dur),
1135 }
1136 } else {
1137 fut.await
1138 }
1139 }
1140}
1141
1142fn error_kind(e: &Error) -> String {
1144 match e {
1145 Error::Parse { .. } => "parse_error".to_string(),
1146 Error::Execution(_) => "execution_error".to_string(),
1147 Error::Io(_) => "io_error".to_string(),
1148 Error::ResourceLimit(_) => "resource_limit".to_string(),
1149 Error::Network(_) => "network_error".to_string(),
1150 Error::Regex(_) => "regex_error".to_string(),
1151 Error::CommandFailure(_) => "execution_error".to_string(),
1152 Error::Internal(_) => "internal_error".to_string(),
1153 Error::Cancelled => "cancelled".to_string(),
1154 }
1155}
1156
1157pub(crate) fn timeout_response(dur: Duration) -> ToolResponse {
1159 ToolResponse {
1160 stdout: String::new(),
1161 stderr: format!(
1162 "bashkit: execution timed out after {:.1}s\n",
1163 dur.as_secs_f64()
1164 ),
1165 exit_code: 124,
1166 error: Some("timeout".to_string()),
1167 ..Default::default()
1168 }
1169}
1170
1171pub(crate) fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str {
1172 if locale.starts_with("uk") { uk } else { en }
1173}
1174
1175fn build_bash_description(locale: &str, builtin_names: &[String]) -> String {
1176 let mut desc = localized(
1177 locale,
1178 "Run bash commands in an isolated virtual filesystem",
1179 "Виконує bash-команди в ізольованій віртуальній файловій системі",
1180 )
1181 .to_string();
1182 if !builtin_names.is_empty() {
1183 desc.push_str(". ");
1184 desc.push_str(localized(
1185 locale,
1186 "Custom commands",
1187 "Користувацькі команди",
1188 ));
1189 desc.push_str(": ");
1190 desc.push_str(&builtin_names.join(", "));
1191 }
1192 desc
1193}
1194
1195fn build_bash_system_prompt(tool: &BashTool) -> String {
1196 let mut parts = vec![format!(
1197 "{}: {}.",
1198 tool.name(),
1199 localized(
1200 tool.locale(),
1201 "run bash commands in an isolated virtual filesystem",
1202 "виконує bash-команди в ізольованій віртуальній файловій системі",
1203 )
1204 )];
1205
1206 parts.push(
1207 localized(
1208 tool.locale(),
1209 "Returns JSON with stdout, stderr, exit_code.",
1210 "Повертає JSON з stdout, stderr, exit_code.",
1211 )
1212 .to_string(),
1213 );
1214
1215 if let Some(username) = &tool.username {
1216 parts.push(format!(
1217 "{} /home/{username}.",
1218 localized(tool.locale(), "Home", "Домівка")
1219 ));
1220 }
1221
1222 parts.push(
1224 localized(
1225 tool.locale(),
1226 "Use bash syntax; do not assume /bin/sh portability ($RANDOM, arrays, [[ ]] require bash).",
1227 "Використовуйте синтаксис bash; не покладайтеся на /bin/sh ($RANDOM, масиви, [[ ]] потребують bash).",
1228 )
1229 .to_string(),
1230 );
1231 parts.push(
1232 localized(
1233 tool.locale(),
1234 "Use `source` only when current-shell state must persist; otherwise run scripts directly.",
1235 "Використовуйте `source` лише коли стан оболонки має зберігатися; інакше запускайте скрипти напряму.",
1236 )
1237 .to_string(),
1238 );
1239 parts.push(
1240 localized(
1241 tool.locale(),
1242 "For large multi-file writes, prefer incremental batches over one giant script.",
1243 "Для масових записів файлів віддавайте перевагу інкрементальним пакетам замість одного великого скрипту.",
1244 )
1245 .to_string(),
1246 );
1247
1248 if let Some(ref limits) = tool.limits {
1250 let mut limit_parts = Vec::new();
1251 limit_parts.push(format!("max_commands={}", limits.max_commands));
1252 limit_parts.push(format!(
1253 "max_loop_iterations={}",
1254 limits.max_loop_iterations
1255 ));
1256 parts.push(format!(
1257 "{}: {}.",
1258 localized(tool.locale(), "Limits", "Ліміти"),
1259 limit_parts.join(", ")
1260 ));
1261 }
1262
1263 if !tool.builtin_hints.is_empty() {
1264 parts.extend(tool.builtin_hints.iter().cloned());
1265 }
1266
1267 if let Some(warning) = tool.language_warning() {
1268 parts.push(warning);
1269 }
1270
1271 parts.join(" ")
1272}
1273
1274fn build_bash_help(tool: &BashTool) -> String {
1275 let mut doc = String::new();
1276 doc.push_str(&format!("# {}\n\n", tool.display_name()));
1277 doc.push_str(tool.description());
1278 doc.push_str(".\n\n");
1279 doc.push_str(&format!(
1280 "**Version:** {}\n**Name:** `{}`\n**Locale:** `{}`\n\n",
1281 tool.version(),
1282 tool.name(),
1283 tool.locale()
1284 ));
1285
1286 doc.push_str("## Parameters\n\n");
1287 doc.push_str("| Name | Type | Required | Default | Description |\n");
1288 doc.push_str("|------|------|----------|---------|-------------|\n");
1289 doc.push_str("| `commands` | string | yes | — | Bash commands to execute |\n");
1290 doc.push_str("| `timeout_ms` | integer | no | — | Per-call timeout in milliseconds |\n\n");
1291
1292 doc.push_str("## Result\n\n");
1293 doc.push_str("| Field | Type | Description |\n");
1294 doc.push_str("|------|------|-------------|\n");
1295 doc.push_str("| `stdout` | string | Standard output |\n");
1296 doc.push_str("| `stderr` | string | Standard error |\n");
1297 doc.push_str("| `exit_code` | integer | Shell exit code |\n");
1298 doc.push_str("| `error` | string | Error category when execution fails |\n\n");
1299
1300 doc.push_str("## Examples\n\n");
1301 doc.push_str("```json\n");
1302 doc.push_str("{\"commands\":\"echo hello\"}\n");
1303 doc.push_str("```\n\n");
1304
1305 doc.push_str("```json\n");
1306 doc.push_str(
1307 "{\"commands\":\"echo data > /tmp/f.txt && cat /tmp/f.txt\",\"timeout_ms\":5000}\n",
1308 );
1309 doc.push_str("```\n\n");
1310
1311 doc.push_str("## Behavior\n\n");
1312 doc.push_str("- Filesystem is virtual and isolated per execution.\n");
1313 doc.push_str("- Standard bash syntax is supported, including pipes, redirects, loops, functions, and arrays.\n");
1314 doc.push_str("- Builtins available by default: `");
1315 doc.push_str(BUILTINS);
1316 doc.push_str("`\n");
1317 if !tool.builtin_names.is_empty() {
1318 doc.push_str("- Custom commands: `");
1319 doc.push_str(&tool.builtin_names.join("`, `"));
1320 doc.push_str("`\n");
1321 }
1322 if let Some(username) = &tool.username {
1323 doc.push_str(&format!("- User: `{username}`\n"));
1324 }
1325 if let Some(hostname) = &tool.hostname {
1326 doc.push_str(&format!("- Host: `{hostname}`\n"));
1327 }
1328 if let Some(limits) = &tool.limits {
1329 doc.push_str(&format!(
1330 "- Limits: {} commands, {} loop iterations, {} function depth\n",
1331 limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
1332 ));
1333 }
1334 if !tool.env_vars.is_empty() {
1335 let env_keys: Vec<&str> = tool.env_vars.iter().map(|(key, _)| key.as_str()).collect();
1336 doc.push_str("- Environment variables: `");
1337 doc.push_str(&env_keys.join("`, `"));
1338 doc.push_str("`\n");
1339 }
1340 if !tool.builtin_hints.is_empty() {
1341 doc.push_str("\n## Notes\n\n");
1342 for hint in &tool.builtin_hints {
1343 doc.push_str("- ");
1344 doc.push_str(hint);
1345 doc.push('\n');
1346 }
1347 }
1348 if let Some(warning) = tool.language_warning() {
1349 doc.push_str("\n## Warnings\n\n");
1350 doc.push_str("- ");
1351 doc.push_str(&warning);
1352 doc.push('\n');
1353 }
1354
1355 doc
1356}
1357
1358pub(crate) fn tool_request_from_value(
1359 locale: &str,
1360 args: serde_json::Value,
1361) -> Result<ToolRequest, ToolError> {
1362 let Some(obj) = args.as_object() else {
1363 return Err(ToolError::UserFacing(
1364 localized(
1365 locale,
1366 "tool arguments must be a JSON object",
1367 "аргументи інструмента мають бути JSON-об'єктом",
1368 )
1369 .to_string(),
1370 ));
1371 };
1372
1373 let Some(commands) = obj.get("commands").and_then(|value| value.as_str()) else {
1374 return Err(ToolError::UserFacing(
1375 localized(
1376 locale,
1377 "`commands` is required",
1378 "поле `commands` є обов'язковим",
1379 )
1380 .to_string(),
1381 ));
1382 };
1383
1384 let timeout_ms = match obj.get("timeout_ms") {
1385 Some(value) => Some(value.as_u64().ok_or_else(|| {
1386 ToolError::UserFacing(
1387 localized(
1388 locale,
1389 "`timeout_ms` must be an integer",
1390 "поле `timeout_ms` має бути цілим числом",
1391 )
1392 .to_string(),
1393 )
1394 })?),
1395 None => None,
1396 };
1397
1398 Ok(ToolRequest {
1399 commands: commands.to_string(),
1400 timeout_ms,
1401 })
1402}
1403
1404pub(crate) fn tool_output_from_response(
1405 response: ToolResponse,
1406 duration: Duration,
1407) -> Result<ToolOutput, ToolError> {
1408 let exit_code = response.exit_code;
1409 let result = serde_json::to_value(response)
1410 .map_err(|err| ToolError::Internal(format!("failed to serialize tool response: {err}")))?;
1411 Ok(ToolOutput {
1412 result,
1413 images: Vec::new(),
1414 metadata: ToolOutputMetadata {
1415 duration,
1416 extra: serde_json::json!({ "exit_code": exit_code }),
1417 },
1418 })
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424
1425 #[test]
1426 fn test_bash_tool_builder() {
1427 let tool = BashTool::builder()
1428 .username("testuser")
1429 .hostname("testhost")
1430 .env("FOO", "bar")
1431 .limits(ExecutionLimits::new().max_commands(100))
1432 .build();
1433
1434 assert_eq!(tool.username, Some("testuser".to_string()));
1435 assert_eq!(tool.hostname, Some("testhost".to_string()));
1436 assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
1437 }
1438
1439 #[test]
1440 fn test_tool_trait_methods() {
1441 let tool = BashTool::default();
1442
1443 assert_eq!(tool.name(), "bashkit");
1445 assert_eq!(tool.display_name(), "Bash");
1446 assert_eq!(
1447 tool.short_description(),
1448 "Run bash commands in an isolated virtual filesystem"
1449 );
1450 assert_eq!(
1451 tool.description(),
1452 "Run bash commands in an isolated virtual filesystem"
1453 );
1454 assert_eq!(tool.locale(), "en-US");
1455 assert!(tool.help().contains("# Bash"));
1456 assert!(tool.help().contains("## Parameters"));
1457 assert!(tool.system_prompt().starts_with("bashkit:"));
1458 assert_eq!(tool.version(), VERSION);
1459 }
1460
1461 #[test]
1462 fn test_jq_help_matches_enabled_feature() {
1463 let helptext = BashTool::default().help();
1464 assert_eq!(helptext.contains("awk jq head"), cfg!(feature = "jq"));
1465 }
1466
1467 #[test]
1468 fn test_tool_description_with_config() {
1469 let tool = BashTool::builder()
1470 .username("agent")
1471 .hostname("sandbox")
1472 .env("API_KEY", "secret")
1473 .limits(ExecutionLimits::new().max_commands(50))
1474 .build();
1475
1476 let helptext = tool.help();
1478 assert!(helptext.contains("User: `agent`"));
1479 assert!(helptext.contains("Host: `sandbox`"));
1480 assert!(helptext.contains("50 commands"));
1481 assert!(helptext.contains("API_KEY"));
1482
1483 let sysprompt = tool.system_prompt();
1485 assert!(sysprompt.starts_with("bashkit:"));
1486 assert!(sysprompt.contains("Home /home/agent."));
1487 assert!(
1488 sysprompt.contains("bash syntax"),
1489 "system_prompt should warn about bash vs sh"
1490 );
1491 assert!(
1492 sysprompt.contains("incremental batches"),
1493 "system_prompt should recommend chunked writes"
1494 );
1495 assert!(
1496 sysprompt.contains("max_commands=50"),
1497 "system_prompt should surface configured limits"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_tool_schemas() {
1503 let tool = BashTool::default();
1504 let input_schema = tool.input_schema();
1505 let output_schema = tool.output_schema();
1506
1507 assert!(input_schema["properties"]["commands"].is_object());
1509
1510 assert!(output_schema["properties"]["stdout"].is_object());
1512 assert!(output_schema["properties"]["stderr"].is_object());
1513 assert!(output_schema["properties"]["exit_code"].is_object());
1514 }
1515
1516 #[test]
1517 fn test_builder_contract_helpers() {
1518 let builder = BashTool::builder().username("agent");
1519 let definition = builder.build_tool_definition();
1520 let input_schema = builder.build_input_schema();
1521 let output_schema = builder.build_output_schema();
1522
1523 assert_eq!(definition["type"], "function");
1524 assert_eq!(definition["function"]["name"], "bashkit");
1525 assert_eq!(definition["function"]["parameters"], input_schema);
1526 assert!(output_schema["properties"]["stdout"].is_object());
1527 }
1528
1529 #[tokio::test]
1530 async fn test_builder_service_executes() {
1531 use tower::ServiceExt;
1532
1533 let service = BashTool::builder().build_service();
1534 let result = service
1535 .oneshot(serde_json::json!({"commands": "echo hello"}))
1536 .await
1537 .unwrap_or_else(|err| panic!("service should execute: {err}"));
1538
1539 assert_eq!(result["stdout"], "hello\n");
1540 assert_eq!(result["exit_code"], 0);
1541 }
1542
1543 #[test]
1544 fn test_execution_rejects_invalid_args() {
1545 let tool = BashTool::default();
1546 let err = tool
1547 .execution(serde_json::json!({"timeout_ms": 10}))
1548 .err()
1549 .unwrap_or_else(|| panic!("execution should reject missing commands"));
1550 assert_eq!(
1551 err,
1552 ToolError::UserFacing("`commands` is required".to_string())
1553 );
1554 }
1555
1556 #[tokio::test]
1557 async fn test_execution_returns_tool_output() {
1558 let tool = BashTool::default();
1559 let execution = tool
1560 .execution(serde_json::json!({"commands": "echo hello"}))
1561 .unwrap_or_else(|err| panic!("execution should be created: {err}"));
1562 let output = execution
1563 .execute()
1564 .await
1565 .unwrap_or_else(|err| panic!("execution should succeed: {err}"));
1566
1567 assert_eq!(output.result["stdout"], "hello\n");
1568 assert_eq!(output.metadata.extra["exit_code"], 0);
1569 assert!(output.metadata.duration >= Duration::from_millis(0));
1570 }
1571
1572 #[tokio::test]
1573 async fn test_configure_applies_to_underlying_bash_builder() {
1574 let tool = BashTool::builder()
1577 .env("FROM_CONVENIENCE", "a")
1578 .configure(|b| b.env("FROM_CONFIGURE", "b").username("via_configure"))
1579 .build();
1580 let output = tool
1581 .execution(
1582 serde_json::json!({"commands": "echo $FROM_CONVENIENCE $FROM_CONFIGURE $USER"}),
1583 )
1584 .unwrap_or_else(|err| panic!("execution should be created: {err}"))
1585 .execute()
1586 .await
1587 .unwrap_or_else(|err| panic!("execution should succeed: {err}"));
1588 assert_eq!(output.result["stdout"], "a b via_configure\n");
1589 }
1590
1591 #[tokio::test]
1592 async fn test_execution_stream_emits_output_chunks() {
1593 use futures_util::StreamExt;
1594
1595 let tool = BashTool::default();
1596 let execution = tool
1597 .execution(serde_json::json!({"commands": "for i in 1 2; do echo $i; done"}))
1598 .unwrap_or_else(|err| panic!("execution should be created: {err}"));
1599 let mut stream = execution
1600 .output_stream()
1601 .unwrap_or_else(|| panic!("stream should be available"));
1602
1603 let handle = tokio::spawn(async move {
1604 execution
1605 .execute()
1606 .await
1607 .unwrap_or_else(|err| panic!("execution should succeed: {err}"))
1608 });
1609
1610 let mut chunks = Vec::new();
1611 while let Some(chunk) = stream.next().await {
1612 chunks.push(chunk);
1613 }
1614
1615 let output = handle
1616 .await
1617 .unwrap_or_else(|err| panic!("join should succeed: {err}"));
1618
1619 assert_eq!(output.result["stdout"], "1\n2\n");
1620 assert_eq!(chunks.len(), 2);
1621 assert_eq!(chunks[0].kind, "stdout");
1622 assert_eq!(chunks[0].data, serde_json::json!("1\n"));
1623 }
1624
1625 #[test]
1626 fn test_locale_localizes_user_facing_text() {
1627 let tool = BashTool::builder().locale("uk-UA").build();
1628 assert_eq!(tool.display_name(), "Баш");
1629 assert_eq!(
1630 tool.description(),
1631 "Виконує bash-команди в ізольованій віртуальній файловій системі"
1632 );
1633 assert!(tool.system_prompt().starts_with("bashkit:"));
1634 }
1635
1636 #[test]
1637 fn test_tool_status() {
1638 let status = ToolStatus::new("execute")
1639 .with_message("Running commands")
1640 .with_percent(50.0)
1641 .with_eta(5000);
1642
1643 assert_eq!(status.phase, "execute");
1644 assert_eq!(status.message, Some("Running commands".to_string()));
1645 assert_eq!(status.percent_complete, Some(50.0));
1646 assert_eq!(status.eta_ms, Some(5000));
1647 }
1648
1649 #[tokio::test]
1650 async fn test_tool_execute_empty() {
1651 let tool = BashTool::default();
1652 let req = ToolRequest {
1653 commands: String::new(),
1654 timeout_ms: None,
1655 };
1656 let resp = tool.execute(req).await;
1657 assert_eq!(resp.exit_code, 0);
1658 assert!(resp.error.is_none());
1659 }
1660
1661 #[tokio::test]
1662 async fn test_tool_execute_echo() {
1663 let tool = BashTool::default();
1664 let req = ToolRequest {
1665 commands: "echo hello".to_string(),
1666 timeout_ms: None,
1667 };
1668 let resp = tool.execute(req).await;
1669 assert_eq!(resp.stdout, "hello\n");
1670 assert_eq!(resp.exit_code, 0);
1671 assert!(resp.error.is_none());
1672 }
1673
1674 #[test]
1675 fn test_bash_tool_builder_cwd() {
1676 let tool = BashTool::builder().cwd("/tmp/work").build();
1677 assert_eq!(tool.cwd, Some(PathBuf::from("/tmp/work")));
1678 }
1679
1680 #[tokio::test]
1681 async fn test_tool_cwd_sets_starting_directory() {
1682 let tool = BashTool::builder().cwd("/tmp").build();
1683 let req = ToolRequest {
1684 commands: "pwd".to_string(),
1685 timeout_ms: None,
1686 };
1687 let resp = tool.execute(req).await;
1688 assert_eq!(resp.stdout, "/tmp\n");
1689 assert_eq!(resp.exit_code, 0);
1690 }
1691
1692 #[test]
1693 fn test_builtin_hints_in_help_and_system_prompt() {
1694 use crate::builtins::Builtin;
1695 use crate::error::Result;
1696 use crate::interpreter::ExecResult;
1697
1698 struct HintedBuiltin;
1699
1700 #[async_trait]
1701 impl Builtin for HintedBuiltin {
1702 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1703 Ok(ExecResult::ok(String::new()))
1704 }
1705 fn llm_hint(&self) -> Option<&'static str> {
1706 Some("mycommand: Processes CSV. Max 10MB. No streaming.")
1707 }
1708 }
1709
1710 let tool = BashTool::builder()
1711 .builtin("mycommand", Box::new(HintedBuiltin))
1712 .build();
1713
1714 let helptext = tool.help();
1716 assert!(
1717 helptext.contains("## Notes"),
1718 "help should have Notes section"
1719 );
1720 assert!(
1721 helptext.contains("mycommand: Processes CSV"),
1722 "help should contain the hint"
1723 );
1724
1725 let sysprompt = tool.system_prompt();
1727 assert!(
1728 sysprompt.contains("mycommand: Processes CSV"),
1729 "system_prompt should contain the hint"
1730 );
1731 }
1732
1733 #[tokio::test]
1734 async fn test_extension_builtins_in_tool() {
1735 use crate::builtins::{Builtin, Extension};
1736 use crate::error::Result;
1737 use crate::interpreter::ExecResult;
1738
1739 struct ToolHello;
1740
1741 #[async_trait]
1742 impl Builtin for ToolHello {
1743 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1744 Ok(ExecResult::ok("hello from tool extension\n".to_string()))
1745 }
1746 }
1747
1748 struct ToolExtension;
1749
1750 impl Extension for ToolExtension {
1751 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
1752 vec![(
1753 "tool-hello".to_string(),
1754 Box::new(ToolHello) as Box<dyn Builtin>,
1755 )]
1756 }
1757 }
1758
1759 let tool = BashTool::builder().extension(ToolExtension).build();
1760 let resp = tool
1761 .execute(ToolRequest {
1762 commands: "tool-hello".to_string(),
1763 timeout_ms: None,
1764 })
1765 .await;
1766
1767 assert_eq!(resp.exit_code, 0);
1768 assert_eq!(resp.stdout, "hello from tool extension\n");
1769 assert!(tool.help().contains("tool-hello"));
1770 }
1771
1772 #[tokio::test]
1773 async fn test_tool_extension_duplicate_names_document_once() {
1774 use crate::builtins::{Builtin, Extension};
1775 use crate::error::Result;
1776 use crate::interpreter::ExecResult;
1777
1778 struct First;
1779 struct Second;
1780
1781 #[async_trait]
1782 impl Builtin for First {
1783 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1784 Ok(ExecResult::ok("first\n".to_string()))
1785 }
1786 }
1787
1788 #[async_trait]
1789 impl Builtin for Second {
1790 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1791 Ok(ExecResult::ok("second\n".to_string()))
1792 }
1793 }
1794
1795 struct OverrideExtension;
1796
1797 impl Extension for OverrideExtension {
1798 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
1799 vec![("dup".to_string(), Box::new(Second) as Box<dyn Builtin>)]
1800 }
1801 }
1802
1803 let tool = BashTool::builder()
1804 .builtin("dup", Box::new(First))
1805 .extension(OverrideExtension)
1806 .build();
1807 let resp = tool
1808 .execute(ToolRequest {
1809 commands: "dup".to_string(),
1810 timeout_ms: None,
1811 })
1812 .await;
1813
1814 assert_eq!(resp.stdout, "second\n");
1815 assert_eq!(tool.help().matches("`dup`").count(), 1);
1816 }
1817
1818 #[test]
1819 fn test_no_hints_without_hinted_builtins() {
1820 let tool = BashTool::default();
1821
1822 let helptext = tool.help();
1823 assert!(
1824 !helptext.contains("## Notes"),
1825 "help should not have Notes without hinted builtins"
1826 );
1827
1828 let sysprompt = tool.system_prompt();
1829 assert!(
1830 !sysprompt.contains("Processes CSV"),
1831 "system_prompt should not have hints without hinted builtins"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_language_warning_default() {
1837 let tool = BashTool::default();
1838
1839 let sysprompt = tool.system_prompt();
1840 assert!(
1841 sysprompt.contains("perl, python/python3 not available."),
1842 "system_prompt should have single combined warning"
1843 );
1844
1845 let helptext = tool.help();
1846 assert!(
1847 helptext.contains("## Warnings"),
1848 "help should have Warnings section"
1849 );
1850 assert!(
1851 helptext.contains("perl, python/python3 not available."),
1852 "help should have single combined warning"
1853 );
1854 }
1855
1856 #[test]
1857 fn test_language_warning_suppressed_by_custom_builtins() {
1858 use crate::builtins::Builtin;
1859 use crate::error::Result;
1860 use crate::interpreter::ExecResult;
1861
1862 struct NoopBuiltin;
1863
1864 #[async_trait]
1865 impl Builtin for NoopBuiltin {
1866 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1867 Ok(ExecResult::ok(String::new()))
1868 }
1869 }
1870
1871 let tool = BashTool::builder()
1872 .builtin("python", Box::new(NoopBuiltin))
1873 .builtin("perl", Box::new(NoopBuiltin))
1874 .build();
1875
1876 let sysprompt = tool.system_prompt();
1877 assert!(
1878 !sysprompt.contains("not available"),
1879 "no warning when all languages registered"
1880 );
1881
1882 let helptext = tool.help();
1883 assert!(
1884 !helptext.contains("## Warnings"),
1885 "no Warnings section when all languages registered"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_language_warning_partial() {
1891 use crate::builtins::Builtin;
1892 use crate::error::Result;
1893 use crate::interpreter::ExecResult;
1894
1895 struct NoopBuiltin;
1896
1897 #[async_trait]
1898 impl Builtin for NoopBuiltin {
1899 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1900 Ok(ExecResult::ok(String::new()))
1901 }
1902 }
1903
1904 let tool = BashTool::builder()
1906 .builtin("python3", Box::new(NoopBuiltin))
1907 .build();
1908
1909 let sysprompt = tool.system_prompt();
1910 assert!(
1911 sysprompt.contains("perl not available."),
1912 "should warn about perl only"
1913 );
1914 assert!(
1915 !sysprompt.contains("python/python3"),
1916 "python warning suppressed when python3 registered"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_duplicate_hints_deduplicated() {
1922 use crate::builtins::Builtin;
1923 use crate::error::Result;
1924 use crate::interpreter::ExecResult;
1925
1926 struct SameHint;
1927
1928 #[async_trait]
1929 impl Builtin for SameHint {
1930 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1931 Ok(ExecResult::ok(String::new()))
1932 }
1933 fn llm_hint(&self) -> Option<&'static str> {
1934 Some("same hint")
1935 }
1936 }
1937
1938 let tool = BashTool::builder()
1939 .builtin("cmd1", Box::new(SameHint))
1940 .builtin("cmd2", Box::new(SameHint))
1941 .build();
1942
1943 let helptext = tool.help();
1944 assert_eq!(
1946 helptext.matches("same hint").count(),
1947 1,
1948 "Duplicate hints should be deduplicated"
1949 );
1950 }
1951
1952 #[cfg(feature = "python")]
1953 #[test]
1954 fn test_python_hint_via_builder() {
1955 let tool = BashTool::builder().python().build();
1956
1957 let helptext = tool.help();
1958 assert!(helptext.contains("python"), "help should mention python");
1959 assert!(
1960 helptext.contains("open() against the VFS"),
1961 "help should document VFS-scoped open() support"
1962 );
1963 assert!(
1964 helptext.contains("No HTTP"),
1965 "help should document HTTP limitation"
1966 );
1967
1968 let sysprompt = tool.system_prompt();
1969 assert!(
1970 sysprompt.contains("python"),
1971 "system_prompt should mention python"
1972 );
1973
1974 assert!(
1976 !sysprompt.contains("python/python3 not available"),
1977 "python warning should not appear when Monty python enabled"
1978 );
1979 }
1980
1981 #[tokio::test]
1982 async fn test_tool_execute_with_status() {
1983 use std::sync::{Arc, Mutex};
1984
1985 let tool = BashTool::default();
1986 let req = ToolRequest {
1987 commands: "echo test".to_string(),
1988 timeout_ms: None,
1989 };
1990
1991 let phases = Arc::new(Mutex::new(Vec::new()));
1992 let phases_clone = phases.clone();
1993
1994 let resp = tool
1995 .execute_with_status(
1996 req,
1997 Box::new(move |status| {
1998 phases_clone
1999 .lock()
2000 .expect("lock poisoned")
2001 .push(status.phase.clone());
2002 }),
2003 )
2004 .await;
2005
2006 assert_eq!(resp.stdout, "test\n");
2007 let phases = phases.lock().expect("lock poisoned");
2008 assert!(phases.contains(&"validate".to_string()));
2009 assert!(phases.contains(&"complete".to_string()));
2010 }
2011
2012 #[tokio::test]
2013 async fn test_execute_with_status_streams_output() {
2014 let tool = BashTool::default();
2015 let req = ToolRequest {
2016 commands: "for i in a b c; do echo $i; done".to_string(),
2017 timeout_ms: None,
2018 };
2019
2020 let events = Arc::new(Mutex::new(Vec::new()));
2021 let events_clone = events.clone();
2022
2023 let resp = tool
2024 .execute_with_status(
2025 req,
2026 Box::new(move |status| {
2027 events_clone.lock().expect("lock poisoned").push(status);
2028 }),
2029 )
2030 .await;
2031
2032 assert_eq!(resp.stdout, "a\nb\nc\n");
2033 assert_eq!(resp.exit_code, 0);
2034
2035 let events = events.lock().expect("lock poisoned");
2036 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
2038 assert_eq!(
2039 output_events.len(),
2040 3,
2041 "expected 3 output events, got {output_events:?}"
2042 );
2043 assert_eq!(output_events[0].output.as_deref(), Some("a\n"));
2044 assert_eq!(output_events[0].stream.as_deref(), Some("stdout"));
2045 assert_eq!(output_events[1].output.as_deref(), Some("b\n"));
2046 assert_eq!(output_events[2].output.as_deref(), Some("c\n"));
2047 }
2048
2049 #[tokio::test]
2050 async fn test_execute_with_status_streams_list_commands() {
2051 let tool = BashTool::default();
2052 let req = ToolRequest {
2053 commands: "echo start; echo end".to_string(),
2054 timeout_ms: None,
2055 };
2056
2057 let events = Arc::new(Mutex::new(Vec::new()));
2058 let events_clone = events.clone();
2059
2060 let resp = tool
2061 .execute_with_status(
2062 req,
2063 Box::new(move |status| {
2064 events_clone.lock().expect("lock poisoned").push(status);
2065 }),
2066 )
2067 .await;
2068
2069 assert_eq!(resp.stdout, "start\nend\n");
2070
2071 let events = events.lock().expect("lock poisoned");
2072 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
2073 assert_eq!(
2074 output_events.len(),
2075 2,
2076 "expected 2 output events, got {output_events:?}"
2077 );
2078 assert_eq!(output_events[0].output.as_deref(), Some("start\n"));
2079 assert_eq!(output_events[1].output.as_deref(), Some("end\n"));
2080 }
2081
2082 #[tokio::test]
2083 async fn test_execute_with_status_no_duplicate_output() {
2084 let tool = BashTool::default();
2085 let req = ToolRequest {
2087 commands: "echo start; for i in 1 2 3; do echo $i; done; echo end".to_string(),
2088 timeout_ms: None,
2089 };
2090
2091 let events = Arc::new(Mutex::new(Vec::new()));
2092 let events_clone = events.clone();
2093
2094 let resp = tool
2095 .execute_with_status(
2096 req,
2097 Box::new(move |status| {
2098 events_clone.lock().expect("lock poisoned").push(status);
2099 }),
2100 )
2101 .await;
2102
2103 assert_eq!(resp.stdout, "start\n1\n2\n3\nend\n");
2104
2105 let events = events.lock().expect("lock poisoned");
2106 let output_events: Vec<_> = events
2107 .iter()
2108 .filter(|s| s.phase == "output")
2109 .map(|s| s.output.as_deref().unwrap_or(""))
2110 .collect();
2111 assert_eq!(
2112 output_events,
2113 vec!["start\n", "1\n", "2\n", "3\n", "end\n"],
2114 "should have exactly 5 distinct output events"
2115 );
2116 }
2117
2118 #[test]
2119 fn test_tool_status_stdout_constructor() {
2120 let status = ToolStatus::stdout("hello\n");
2121 assert_eq!(status.phase, "output");
2122 assert_eq!(status.output.as_deref(), Some("hello\n"));
2123 assert_eq!(status.stream.as_deref(), Some("stdout"));
2124 assert!(status.message.is_none());
2125 }
2126
2127 #[test]
2128 fn test_tool_status_stderr_constructor() {
2129 let status = ToolStatus::stderr("error\n");
2130 assert_eq!(status.phase, "output");
2131 assert_eq!(status.output.as_deref(), Some("error\n"));
2132 assert_eq!(status.stream.as_deref(), Some("stderr"));
2133 }
2134
2135 #[tokio::test]
2136 async fn test_tool_execute_timeout() {
2137 let tool = BashTool::default();
2138 let req = ToolRequest {
2139 commands: "sleep 10".to_string(),
2140 timeout_ms: Some(100),
2141 };
2142 let resp = tool.execute(req).await;
2143 assert_eq!(resp.exit_code, 124);
2144 assert!(resp.stderr.contains("timed out"));
2145 assert_eq!(resp.error, Some("timeout".to_string()));
2146 }
2147
2148 #[tokio::test]
2149 async fn test_tool_execute_no_timeout() {
2150 let tool = BashTool::default();
2151 let req = ToolRequest {
2152 commands: "echo fast".to_string(),
2153 timeout_ms: Some(5000),
2154 };
2155 let resp = tool.execute(req).await;
2156 assert_eq!(resp.exit_code, 0);
2157 assert_eq!(resp.stdout, "fast\n");
2158 }
2159
2160 #[test]
2161 fn test_tool_request_new() {
2162 let req = ToolRequest::new("echo test");
2163 assert_eq!(req.commands, "echo test");
2164 assert_eq!(req.timeout_ms, None);
2165 }
2166
2167 #[test]
2168 fn test_tool_request_deserialize_without_timeout() {
2169 let json = r#"{"commands":"echo hello"}"#;
2170 let req: ToolRequest = serde_json::from_str(json).unwrap();
2171 assert_eq!(req.commands, "echo hello");
2172 assert_eq!(req.timeout_ms, None);
2173 }
2174
2175 #[test]
2176 fn test_tool_request_deserialize_with_timeout() {
2177 let json = r#"{"commands":"echo hello","timeout_ms":5000}"#;
2178 let req: ToolRequest = serde_json::from_str(json).unwrap();
2179 assert_eq!(req.commands, "echo hello");
2180 assert_eq!(req.timeout_ms, Some(5000));
2181 }
2182
2183 #[tokio::test]
2185 async fn test_create_bash_preserves_builtins() {
2186 use crate::ExecResult;
2187 use crate::builtins::{Builtin, Context};
2188 use async_trait::async_trait;
2189
2190 struct TestBuiltin;
2191
2192 #[async_trait]
2193 impl Builtin for TestBuiltin {
2194 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
2195 Ok(ExecResult::ok("test_output\n"))
2196 }
2197 }
2198
2199 let tool = BashToolBuilder::new()
2200 .builtin("testcmd", Box::new(TestBuiltin))
2201 .build();
2202
2203 let mut bash1 = tool.create_bash();
2205 let result1 = bash1.exec("testcmd").await.unwrap();
2206 assert!(
2207 result1.stdout.contains("test_output"),
2208 "first call should have custom builtin"
2209 );
2210
2211 let mut bash2 = tool.create_bash();
2213 let result2 = bash2.exec("testcmd").await.unwrap();
2214 assert!(
2215 result2.stdout.contains("test_output"),
2216 "second call should still have custom builtin"
2217 );
2218 }
2219}