1use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use std::collections::HashMap;
17use std::sync::{Arc, OnceLock};
18use tracing::error;
19
20use crate::background::{
21 BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
22};
23use crate::tool_types::{
24 BuiltinTool, DeferrablePolicy, ToolCall, ToolDefinition, ToolHints, ToolPolicy, ToolResult,
25};
26use crate::traits::ToolContext;
27use crate::typed_id::SessionId;
28use tokio::sync::{OwnedSemaphorePermit, Semaphore};
29
30use crate::error::Result;
31use crate::traits::ToolExecutor;
32
33pub const MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION: usize = 5;
38
39const MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER: usize = 64;
44static ACTIVE_BACKGROUND_RUNS_PER_WORKER: Semaphore =
45 Semaphore::const_new(MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER);
46
47static SESSION_BACKGROUND_PERMITS: OnceLock<std::sync::Mutex<HashMap<SessionId, Arc<Semaphore>>>> =
53 OnceLock::new();
54
55struct SessionBackgroundPermit {
56 session_id: SessionId,
57 semaphore: Arc<Semaphore>,
58 permit: Option<OwnedSemaphorePermit>,
59}
60
61impl Drop for SessionBackgroundPermit {
62 fn drop(&mut self) {
63 drop(self.permit.take());
64
65 let Some(permits) = SESSION_BACKGROUND_PERMITS.get() else {
66 return;
67 };
68 let mut permits = permits.lock().unwrap();
69 let should_remove = permits.get(&self.session_id).is_some_and(|current| {
70 Arc::ptr_eq(current, &self.semaphore)
71 && self.semaphore.available_permits() == MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION
72 && Arc::strong_count(&self.semaphore) == 2
73 });
74 if should_remove {
75 permits.remove(&self.session_id);
76 }
77 }
78}
79
80fn try_acquire_session_background_permit(
81 session_id: SessionId,
82) -> std::result::Result<SessionBackgroundPermit, tokio::sync::TryAcquireError> {
83 let permits = SESSION_BACKGROUND_PERMITS.get_or_init(Default::default);
84 let mut permits = permits.lock().unwrap();
85 let semaphore = permits
86 .entry(session_id)
87 .or_insert_with(|| Arc::new(Semaphore::new(MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION)))
88 .clone();
89 let permit = semaphore.clone().try_acquire_owned()?;
90
91 Ok(SessionBackgroundPermit {
92 session_id,
93 semaphore,
94 permit: Some(permit),
95 })
96}
97
98#[cfg(test)]
99fn has_session_background_permits(session_id: SessionId) -> bool {
100 SESSION_BACKGROUND_PERMITS
101 .get()
102 .and_then(|permits| permits.lock().unwrap().get(&session_id).cloned())
103 .is_some()
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ToolResultImage {
116 pub base64: String,
118 pub media_type: String,
120}
121
122#[derive(Debug)]
138pub enum ToolExecutionResult {
139 Success(Value),
141
142 SuccessWithImages {
146 result: Value,
147 images: Vec<ToolResultImage>,
148 },
149
150 ToolError(String),
155
156 InternalError(ToolInternalError),
162
163 ConnectionRequired {
170 provider: String,
172 },
173}
174
175impl ToolExecutionResult {
176 pub fn success(value: impl Into<Value>) -> Self {
178 ToolExecutionResult::Success(value.into())
179 }
180
181 pub fn success_with_raw_output(value: impl Into<Value>, raw_output: String) -> Self {
184 let mut value = value.into();
185 match value.as_object_mut() {
189 Some(obj) => {
190 obj.insert("_raw_output".to_string(), Value::String(raw_output));
191 }
192 None => {
193 value = serde_json::json!({
194 "_raw_output_scalar": value,
195 "_raw_output": raw_output,
196 });
197 }
198 }
199 ToolExecutionResult::Success(value)
200 }
201
202 pub fn success_with_images(value: impl Into<Value>, images: Vec<ToolResultImage>) -> Self {
204 ToolExecutionResult::SuccessWithImages {
205 result: value.into(),
206 images,
207 }
208 }
209
210 pub fn tool_error(message: impl Into<String>) -> Self {
212 ToolExecutionResult::ToolError(message.into())
213 }
214
215 pub fn internal_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
217 ToolExecutionResult::InternalError(ToolInternalError::new(error))
218 }
219
220 pub fn internal_error_msg(message: impl Into<String>) -> Self {
222 ToolExecutionResult::InternalError(ToolInternalError::from_message(message))
223 }
224
225 pub fn connection_required(provider: impl Into<String>) -> Self {
227 ToolExecutionResult::ConnectionRequired {
228 provider: provider.into(),
229 }
230 }
231
232 pub fn is_success(&self) -> bool {
234 matches!(
235 self,
236 ToolExecutionResult::Success(_) | ToolExecutionResult::SuccessWithImages { .. }
237 )
238 }
239
240 pub fn is_error(&self) -> bool {
242 matches!(
243 self,
244 ToolExecutionResult::ToolError(_) | ToolExecutionResult::InternalError(_)
245 )
246 }
247
248 pub fn is_connection_required(&self) -> bool {
250 matches!(self, ToolExecutionResult::ConnectionRequired { .. })
251 }
252
253 pub fn into_tool_result(self, tool_call_id: &str, tool_name: &str) -> ToolResult {
261 match self {
262 ToolExecutionResult::Success(mut value) => {
263 let raw_output = value
265 .as_object_mut()
266 .and_then(|obj| obj.remove("_raw_output"))
267 .and_then(|v| v.as_str().map(|s| s.to_string()));
268 let result_value = if let Some(obj) = value.as_object_mut() {
271 let is_scalar_carrier = raw_output.is_some()
272 && obj.len() == 1
273 && obj.contains_key("_raw_output_scalar");
274 if is_scalar_carrier {
275 obj.remove("_raw_output_scalar").unwrap_or(Value::Null)
276 } else {
277 value
278 }
279 } else {
280 value
281 };
282 ToolResult {
283 tool_call_id: tool_call_id.to_string(),
284 result: Some(result_value),
285 images: None,
286 error: None,
287 connection_required: None,
288 raw_output,
289 }
290 }
291 ToolExecutionResult::SuccessWithImages { result, images } => ToolResult {
292 tool_call_id: tool_call_id.to_string(),
293 result: Some(result),
294 images: if images.is_empty() {
295 None
296 } else {
297 Some(images)
298 },
299 error: None,
300 connection_required: None,
301 raw_output: None,
302 },
303 ToolExecutionResult::ToolError(message) => ToolResult {
304 tool_call_id: tool_call_id.to_string(),
305 result: Some(serde_json::json!({ "error": &message })),
306 images: None,
307 error: Some(message),
308 connection_required: None,
309 raw_output: None,
310 },
311 ToolExecutionResult::InternalError(err) => {
312 error!(
314 tool_name = %tool_name,
315 tool_call_id = %tool_call_id,
316 error = %err.message,
317 error_chain = %err.chain_string(),
318 "Tool internal error (details hidden from LLM)"
319 );
320
321 let generic_msg = "An internal error occurred while executing the tool";
323 ToolResult {
324 tool_call_id: tool_call_id.to_string(),
325 result: Some(serde_json::json!({
326 "error": generic_msg
327 })),
328 images: None,
329 error: Some(generic_msg.to_string()),
330 connection_required: None,
331 raw_output: None,
332 }
333 }
334 ToolExecutionResult::ConnectionRequired { ref provider } => ToolResult {
335 tool_call_id: tool_call_id.to_string(),
336 result: Some(serde_json::json!({
337 "connection_required": provider,
338 })),
339 images: None,
340 error: None,
341 connection_required: Some(provider.clone()),
342 raw_output: None,
343 },
344 }
345 }
346}
347
348#[derive(Debug)]
350pub struct ToolInternalError {
351 pub message: String,
353 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
355}
356
357impl ToolInternalError {
358 pub fn new(error: impl std::error::Error + Send + Sync + 'static) -> Self {
360 Self {
361 message: error.to_string(),
362 source: Some(Box::new(error)),
363 }
364 }
365
366 pub fn from_message(message: impl Into<String>) -> Self {
368 Self {
369 message: message.into(),
370 source: None,
371 }
372 }
373
374 pub fn chain_string(&self) -> String {
375 let mut parts = vec![self.message.clone()];
376 let mut current = <Self as std::error::Error>::source(self);
377 while let Some(source) = current {
378 let message = source.to_string();
379 if parts.last() != Some(&message) {
380 parts.push(message);
381 }
382 current = source.source();
383 }
384 parts.join(": ")
385 }
386}
387
388impl std::fmt::Display for ToolInternalError {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 write!(f, "{}", self.message)
391 }
392}
393
394impl std::error::Error for ToolInternalError {
395 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
396 self.source
397 .as_ref()
398 .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
399 }
400}
401
402#[async_trait]
451pub trait Tool: Send + Sync {
452 fn name(&self) -> &str;
457
458 fn display_name(&self) -> Option<&str> {
465 None
466 }
467
468 fn description(&self) -> &str;
473
474 fn parameters_schema(&self) -> Value;
480
481 async fn execute(&self, arguments: Value) -> ToolExecutionResult;
492
493 async fn execute_with_context(
510 &self,
511 arguments: Value,
512 _context: &ToolContext,
513 ) -> ToolExecutionResult {
514 self.execute(arguments).await
516 }
517
518 fn requires_context(&self) -> bool {
523 false
524 }
525
526 fn policy(&self) -> ToolPolicy {
531 ToolPolicy::Auto
532 }
533
534 fn hints(&self) -> ToolHints {
539 ToolHints::default()
540 }
541
542 fn narrate(
552 &self,
553 _tool_call: &crate::tool_types::ToolCall,
554 _phase: crate::tool_narration::ToolNarrationPhase,
555 _locale: Option<&str>,
556 ) -> Option<String> {
557 None
558 }
559
560 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
563 None
564 }
565
566 fn deferrable_policy(&self) -> DeferrablePolicy {
571 DeferrablePolicy::default()
572 }
573
574 fn to_definition(&self) -> ToolDefinition {
579 ToolDefinition::Builtin(BuiltinTool {
580 name: self.name().to_string(),
581 display_name: self.display_name().map(|s| s.to_string()),
582 description: self.description().to_string(),
583 parameters: self.parameters_schema(),
584 policy: self.policy(),
585 category: None,
586 deferrable: self.deferrable_policy(),
587 hints: self.hints(),
588 full_parameters: None,
589 })
590 }
591}
592
593#[derive(Default, Clone)]
620pub struct ToolRegistry {
621 tools: HashMap<String, Arc<dyn Tool>>,
622}
623
624impl ToolRegistry {
625 pub fn new() -> Self {
627 Self {
628 tools: HashMap::new(),
629 }
630 }
631
632 pub fn with_defaults() -> Self {
646 use crate::capabilities::{
647 AddTool, DeleteFileTool, DivideTool, EditFileTool, GetCurrentTimeTool, GetForecastTool,
648 GetWeatherTool, GrepFilesTool, ListDirectoryTool, MultiplyTool, ReadFileTool,
649 StatFileTool, SubtractTool, WriteFileTool, WriteTodosTool,
650 };
651 use crate::progress_reporting::ReportProgressTool;
652
653 let builder = ToolRegistry::builder()
654 .tool(GetCurrentTimeTool)
655 .tool(EchoTool)
656 .tool(ReportProgressTool)
666 .tool(AddTool)
668 .tool(SubtractTool)
669 .tool(MultiplyTool)
670 .tool(DivideTool)
671 .tool(GetWeatherTool)
673 .tool(GetForecastTool)
674 .tool(WriteTodosTool)
676 .tool(ReadFileTool)
678 .tool(WriteFileTool)
679 .tool(EditFileTool)
680 .tool(ListDirectoryTool)
681 .tool(GrepFilesTool)
682 .tool(DeleteFileTool)
683 .tool(StatFileTool);
684
685 #[cfg(feature = "web-fetch")]
688 let builder = builder.tool(crate::capabilities::WebFetchTool::default());
689
690 builder.build()
691 }
692
693 pub fn register(&mut self, tool: impl Tool + 'static) {
697 self.tools.insert(tool.name().to_string(), Arc::new(tool));
698 }
699
700 pub fn register_boxed(&mut self, tool: Box<dyn Tool>) {
702 self.tools.insert(tool.name().to_string(), Arc::from(tool));
703 }
704
705 pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
707 self.tools.insert(tool.name().to_string(), tool);
708 }
709
710 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
712 self.tools.get(name)
713 }
714
715 pub fn has(&self, name: &str) -> bool {
717 self.tools.contains_key(name)
718 }
719
720 pub fn len(&self) -> usize {
722 self.tools.len()
723 }
724
725 pub fn is_empty(&self) -> bool {
727 self.tools.is_empty()
728 }
729
730 pub fn tool_names(&self) -> Vec<&str> {
732 self.tools.keys().map(|s| s.as_str()).collect()
733 }
734
735 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
740 self.tools.values().map(|t| t.to_definition()).collect()
741 }
742
743 pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
745 self.tools.remove(name)
746 }
747
748 pub fn clear(&mut self) {
750 self.tools.clear();
751 }
752
753 pub fn builder() -> ToolRegistryBuilder {
755 ToolRegistryBuilder::new()
756 }
757}
758
759impl std::fmt::Debug for ToolRegistry {
760 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761 f.debug_struct("ToolRegistry")
762 .field("tools", &self.tool_names())
763 .finish()
764 }
765}
766
767#[async_trait]
768impl ToolExecutor for ToolRegistry {
769 async fn execute(
770 &self,
771 tool_call: &ToolCall,
772 _tool_def: &ToolDefinition,
773 ) -> Result<ToolResult> {
774 let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
775 crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
776 })?;
777
778 let result = tool.execute(tool_call.arguments.clone()).await;
779 Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
780 }
781
782 async fn execute_with_context(
783 &self,
784 tool_call: &ToolCall,
785 _tool_def: &ToolDefinition,
786 context: &ToolContext,
787 ) -> Result<ToolResult> {
788 let tool = self.tools.get(&tool_call.name).ok_or_else(|| {
789 crate::error::AgentLoopError::tool(format!("Tool not found: {}", tool_call.name))
790 })?;
791
792 let result = tool
795 .execute_with_context(tool_call.arguments.clone(), context)
796 .await;
797 Ok(result.into_tool_result(&tool_call.id, &tool_call.name))
798 }
799}
800
801pub struct ToolRegistryBuilder {
816 registry: ToolRegistry,
817}
818
819impl ToolRegistryBuilder {
820 pub fn new() -> Self {
822 Self {
823 registry: ToolRegistry::new(),
824 }
825 }
826
827 pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
829 self.registry.register(tool);
830 self
831 }
832
833 pub fn tool_boxed(mut self, tool: Box<dyn Tool>) -> Self {
835 self.registry.register_boxed(tool);
836 self
837 }
838
839 pub fn tool_arc(mut self, tool: Arc<dyn Tool>) -> Self {
841 self.registry.register_arc(tool);
842 self
843 }
844
845 pub fn build(self) -> ToolRegistry {
847 self.registry
848 }
849}
850
851impl Default for ToolRegistryBuilder {
852 fn default() -> Self {
853 Self::new()
854 }
855}
856
857pub struct EchoTool;
863
864#[async_trait]
865impl Tool for EchoTool {
866 fn name(&self) -> &str {
867 "echo"
868 }
869
870 fn display_name(&self) -> Option<&str> {
871 Some("Echo")
872 }
873
874 fn description(&self) -> &str {
875 "Echo back the provided message. Useful for testing tool execution."
876 }
877
878 fn parameters_schema(&self) -> Value {
879 serde_json::json!({
880 "type": "object",
881 "properties": {
882 "message": {
883 "type": "string",
884 "description": "The message to echo back"
885 }
886 },
887 "required": ["message"],
888 "additionalProperties": false
889 })
890 }
891
892 fn hints(&self) -> ToolHints {
893 ToolHints::default()
894 .with_readonly(true)
895 .with_idempotent(true)
896 }
897
898 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
899 let message = arguments
900 .get("message")
901 .and_then(|v| v.as_str())
902 .unwrap_or("");
903
904 ToolExecutionResult::success(serde_json::json!({
905 "echoed": message,
906 "length": message.len()
907 }))
908 }
909}
910
911pub struct SpawnBackgroundTool;
913
914#[derive(Debug, Clone)]
915struct BackgroundScheduleRequest {
916 cron_expression: Option<String>,
917 scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
918 timezone: String,
919}
920
921fn parse_background_schedule(
922 arguments: &Value,
923) -> std::result::Result<Option<BackgroundScheduleRequest>, String> {
924 let Some(schedule) = arguments.get("schedule") else {
925 return Ok(None);
926 };
927 let Some(schedule) = schedule.as_object() else {
928 return Err("schedule must be an object".to_string());
929 };
930
931 let cron_expression = schedule
932 .get("cron_expression")
933 .and_then(Value::as_str)
934 .map(str::trim)
935 .filter(|value| !value.is_empty())
936 .map(ToString::to_string);
937 let scheduled_at = match schedule.get("scheduled_at").and_then(Value::as_str) {
938 Some(value) => {
939 let value = value.trim();
940 if value.is_empty() {
941 None
942 } else {
943 Some(
944 chrono::DateTime::parse_from_rfc3339(value)
945 .map_err(|_| "scheduled_at must be RFC3339".to_string())?
946 .with_timezone(&chrono::Utc),
947 )
948 }
949 }
950 None => None,
951 };
952
953 match (cron_expression.is_some(), scheduled_at.is_some()) {
954 (false, false) => {
955 return Err(
956 "schedule must include exactly one of cron_expression (recurring) or scheduled_at (one-shot)"
957 .to_string(),
958 );
959 }
960 (true, true) => {
961 return Err(
962 "schedule must not include both cron_expression and scheduled_at; provide exactly one"
963 .to_string(),
964 );
965 }
966 _ => {}
967 }
968
969 let timezone = schedule
970 .get("timezone")
971 .and_then(Value::as_str)
972 .map(str::trim)
973 .filter(|value| !value.is_empty())
974 .unwrap_or("UTC")
975 .to_string();
976
977 Ok(Some(BackgroundScheduleRequest {
978 cron_expression,
979 scheduled_at,
980 timezone,
981 }))
982}
983
984fn build_background_schedule_description(
985 tool_name: &str,
986 tool_args: &Value,
987 title: &str,
988 signal_on_completion: bool,
989) -> String {
990 let payload = json!({
991 "tool": tool_name,
992 "title": title,
993 "signal_on_completion": signal_on_completion,
994 "args": tool_args,
995 });
996 let payload_json =
997 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
998
999 format!(
1000 "Monitor: {title}\n\n\
1001This scheduled monitor fired. Start the background run now.\n\n\
1002spawn_background payload:\n{payload_json}"
1003 )
1004}
1005
1006#[async_trait]
1007impl Tool for SpawnBackgroundTool {
1008 fn name(&self) -> &str {
1009 "spawn_background"
1010 }
1011
1012 fn display_name(&self) -> Option<&str> {
1013 Some("Spawn Background")
1014 }
1015
1016 fn description(&self) -> &str {
1017 "Run a background-capable built-in tool asynchronously. Returns immediately and signals the session when the background run completes."
1018 }
1019
1020 fn parameters_schema(&self) -> Value {
1021 json!({
1022 "type": "object",
1023 "properties": {
1024 "tool": {
1025 "type": "string",
1026 "description": "Name of the built-in tool to execute in the background"
1027 },
1028 "args": {
1029 "type": "object",
1030 "description": "Arguments to pass to the target tool"
1031 },
1032 "title": {
1033 "type": "string",
1034 "description": "Optional human-readable label for the background run"
1035 },
1036 "schedule": {
1037 "type": "object",
1038 "description": "Optional session schedule. When provided, this creates a scheduled monitor instead of starting the run immediately.",
1039 "properties": {
1040 "cron_expression": {
1041 "type": "string",
1042 "description": "Standard 5-field cron expression for recurring runs (e.g. '*/10 * * * *' for every 10 minutes)"
1043 },
1044 "scheduled_at": {
1045 "type": "string",
1046 "description": "ISO 8601 datetime for a one-shot run (e.g. '2026-04-16T15:30:00Z')"
1047 },
1048 "timezone": {
1049 "type": "string",
1050 "description": "IANA timezone for the schedule. Default: UTC"
1051 }
1052 },
1053 "additionalProperties": false
1054 },
1055 "signal_on_completion": {
1056 "type": "boolean",
1057 "description": "Send a synthetic user message back to the session when the run completes",
1058 "default": true
1059 }
1060 },
1061 "required": ["tool", "args"],
1062 "additionalProperties": false
1063 })
1064 }
1065
1066 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1067 ToolExecutionResult::tool_error(
1068 "spawn_background requires context. This tool must be executed with session context.",
1069 )
1070 }
1071
1072 async fn execute_with_context(
1073 &self,
1074 arguments: Value,
1075 context: &ToolContext,
1076 ) -> ToolExecutionResult {
1077 let tool_name = match arguments.get("tool").and_then(|v| v.as_str()) {
1078 Some(name) if !name.trim().is_empty() => name.trim(),
1079 _ => return ToolExecutionResult::tool_error("Missing required parameter: tool"),
1080 };
1081 let tool_args = match arguments.get("args") {
1082 Some(args) if args.is_object() => args.clone(),
1083 _ => {
1084 return ToolExecutionResult::tool_error(
1085 "Missing required parameter: args (object expected)",
1086 );
1087 }
1088 };
1089 let signal_on_completion = arguments
1090 .get("signal_on_completion")
1091 .and_then(|v| v.as_bool())
1092 .unwrap_or(true);
1093 let schedule_request = match parse_background_schedule(&arguments) {
1094 Ok(schedule) => schedule,
1095 Err(message) => return ToolExecutionResult::tool_error(message),
1096 };
1097
1098 let Some(tool_registry) = &context.tool_registry else {
1099 return ToolExecutionResult::tool_error(
1100 "Tool registry not available in this context. spawn_background requires worker-side tool execution.",
1101 );
1102 };
1103
1104 let Some(tool) = tool_registry.get(tool_name).cloned() else {
1105 return ToolExecutionResult::tool_error(format!("Unknown tool: {tool_name}"));
1106 };
1107 if tool_name == self.name() {
1108 return ToolExecutionResult::tool_error(
1109 "spawn_background cannot target itself recursively",
1110 );
1111 }
1112 if tool.hints().supports_background != Some(true) {
1113 return ToolExecutionResult::tool_error(format!(
1114 "Tool does not support background execution: {tool_name}"
1115 ));
1116 }
1117 if tool.as_background_executable().is_none() {
1118 return ToolExecutionResult::tool_error(format!(
1119 "Tool declared background support but has no background executor: {tool_name}"
1120 ));
1121 }
1122 let title = arguments
1123 .get("title")
1124 .and_then(|v| v.as_str())
1125 .map(str::trim)
1126 .filter(|s| !s.is_empty())
1127 .map(|s| s.to_string())
1128 .unwrap_or_else(|| {
1129 tool.display_name()
1130 .map(ToString::to_string)
1131 .unwrap_or_else(|| format!("Background {tool_name}"))
1132 });
1133
1134 if let Some(schedule_request) = schedule_request {
1135 let Some(schedule_store) = &context.schedule_store else {
1136 return ToolExecutionResult::tool_error(
1137 "Schedule store not available in this context. Scheduled monitors require session schedules.",
1138 );
1139 };
1140
1141 match crate::session_schedule::enforce_create_limits(
1144 schedule_store.as_ref(),
1145 context.session_id,
1146 schedule_request.cron_expression.as_deref(),
1147 )
1148 .await
1149 {
1150 Ok(()) => {}
1151 Err(crate::session_schedule::ScheduleLimitError::Store(err)) => {
1152 return ToolExecutionResult::internal_error(err);
1153 }
1154 Err(crate::session_schedule::ScheduleLimitError::Rejected(msg)) => {
1155 return ToolExecutionResult::tool_error(msg);
1156 }
1157 }
1158
1159 let description = build_background_schedule_description(
1160 tool_name,
1161 &tool_args,
1162 &title,
1163 signal_on_completion,
1164 );
1165
1166 return match schedule_store
1167 .create_schedule(
1168 context.session_id,
1169 description,
1170 schedule_request.cron_expression.clone(),
1171 schedule_request.scheduled_at,
1172 schedule_request.timezone.clone(),
1173 )
1174 .await
1175 {
1176 Ok(schedule) => {
1177 let mut monitor_task_id: Option<String> = None;
1181 if let Some(ref task_registry) = context.session_task_registry {
1182 let spec = json!({
1183 "tool": tool_name,
1184 "arguments": &tool_args,
1185 "schedule_id": schedule.id.to_string(),
1186 "schedule_type": schedule.schedule_type,
1187 "cron_expression": schedule.cron_expression,
1188 "scheduled_at": schedule.scheduled_at,
1189 "timezone": schedule.timezone,
1190 "signal_on_completion": signal_on_completion,
1191 });
1192 match task_registry
1193 .create(crate::session_task::CreateSessionTask {
1194 session_id: context.session_id,
1195 id: None,
1196 kind: crate::session_task::TASK_KIND_MONITOR.to_string(),
1197 display_name: title.clone(),
1198 spec,
1199 state: crate::session_task::SessionTaskState::Running,
1200 links: crate::session_task::TaskLinks::default(),
1201 wake_policy: crate::session_task::TaskWakePolicy::Silent,
1204 })
1205 .await
1206 {
1207 Ok(task) => {
1208 monitor_task_id = Some(task.id);
1209 }
1210 Err(e) => {
1211 tracing::warn!(
1212 session_id = %context.session_id,
1213 schedule_id = %schedule.id,
1214 error = %e,
1215 "Failed to create monitor task for schedule (best-effort)"
1216 );
1217 }
1218 }
1219 }
1220 ToolExecutionResult::success(json!({
1221 "created": true,
1222 "status": "scheduled",
1223 "title": title,
1224 "tool": tool_name,
1225 "signal_on_completion": signal_on_completion,
1226 "schedule_id": schedule.id.to_string(),
1227 "schedule_type": schedule.schedule_type,
1228 "cron_expression": schedule.cron_expression,
1229 "scheduled_at": schedule.scheduled_at,
1230 "timezone": schedule.timezone,
1231 "next_trigger_at": schedule.next_trigger_at,
1232 "enabled": schedule.enabled,
1233 "task_id": monitor_task_id,
1234 }))
1235 }
1236 Err(err) => ToolExecutionResult::internal_error(err),
1237 };
1238 }
1239
1240 let Some(task_registry) = &context.session_task_registry else {
1241 return ToolExecutionResult::tool_error(
1242 "Session task registry not available in this context. Background runs require task tracking.",
1243 );
1244 };
1245 if context.file_store.is_none() {
1246 return ToolExecutionResult::tool_error(
1247 "Session file store not available in this context. spawn_background requires artifact persistence.",
1248 );
1249 }
1250
1251 let background_run_permit = match ACTIVE_BACKGROUND_RUNS_PER_WORKER.try_acquire() {
1252 Ok(permit) => permit,
1253 Err(_) => {
1254 return ToolExecutionResult::tool_error(format!(
1255 "Worker is already running the maximum {MAX_ACTIVE_BACKGROUND_RUNS_PER_WORKER} active background runs. Try again after an existing run finishes."
1256 ));
1257 }
1258 };
1259
1260 let session_run_permit = match try_acquire_session_background_permit(context.session_id) {
1261 Ok(permit) => permit,
1262 Err(_) => {
1263 return ToolExecutionResult::tool_error(format!(
1264 "Maximum {MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION} active background runs per session. Wait for an existing run to finish before starting another."
1265 ));
1266 }
1267 };
1268
1269 let run_id = format!("bg_{}", uuid::Uuid::now_v7().simple());
1270 let artifact_dir = format!("/.background/{run_id}");
1271 let log_path = format!("{artifact_dir}/output.log");
1272 let result_path = format!("{artifact_dir}/result.json");
1273
1274 let (task_id, task_attempt): (Option<String>, i32) = match task_registry
1277 .create(crate::session_task::CreateSessionTask {
1278 session_id: context.session_id,
1279 id: None,
1280 kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
1281 display_name: title.clone(),
1282 spec: json!({
1283 "tool": tool_name,
1284 "arguments": &tool_args,
1285 "reattachable": tool.hints().idempotent.unwrap_or(false)
1288 || tool.hints().readonly.unwrap_or(false),
1289 "signal_on_completion": signal_on_completion,
1291 }),
1292 state: crate::session_task::SessionTaskState::Running,
1293 links: crate::session_task::TaskLinks::default(),
1294 wake_policy: crate::session_task::TaskWakePolicy::Silent,
1295 })
1296 .await
1297 {
1298 Ok(task) => (Some(task.id), task.attempt),
1299 Err(e) => {
1300 return ToolExecutionResult::internal_error_msg(format!(
1301 "Failed to create background run task: {e}"
1302 ));
1303 }
1304 };
1305
1306 let background_context = context.clone().with_tool_registry(tool_registry.clone());
1307 let sink = Arc::new(SessionBackgroundSink::new(
1308 background_context.clone(),
1309 run_id.clone(),
1310 title.clone(),
1311 tool_name.to_string(),
1312 log_path.clone(),
1313 result_path.clone(),
1314 signal_on_completion,
1315 task_id.clone(),
1316 ));
1317 let run_id_for_task = run_id.clone();
1318 let tool_for_task = tool.clone();
1319 let tool_name_for_task = tool_name.to_string();
1320
1321 let cancel_registry = context.session_task_registry.clone();
1323 let cancel_session_id = context.session_id;
1324 let cancel_task_id = task_id.clone();
1325 let cancel_task_attempt = task_attempt;
1327
1328 tokio::spawn(async move {
1329 let _background_run_permit = background_run_permit;
1330 let _session_run_permit = session_run_permit;
1331 let _ = sink.status("Starting").await;
1332
1333 let outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult> = match (
1341 cancel_registry.as_ref(),
1342 cancel_task_id.as_deref(),
1343 ) {
1344 (Some(registry), Some(task_id_str)) => {
1345 let registry = registry.clone();
1346 let task_id_str = task_id_str.to_string();
1347 let tool_fut = async {
1348 match tool_for_task.as_background_executable() {
1349 Some(background_tool) => {
1350 background_tool
1351 .execute_background(tool_args, background_context, sink.clone())
1352 .await
1353 }
1354 None => Err(ToolExecutionResult::tool_error(format!(
1355 "Tool declared background support but has no background executor: {}",
1356 tool_name_for_task
1357 ))),
1358 }
1359 };
1360 let watch_fut = async {
1361 loop {
1362 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1363 let _ = registry
1367 .update(
1368 cancel_session_id,
1369 &task_id_str,
1370 crate::session_task::SessionTaskUpdate {
1371 heartbeat_at: Some(chrono::Utc::now()),
1372 expected_attempt: Some(cancel_task_attempt),
1373 ..Default::default()
1374 },
1375 )
1376 .await;
1377 if let Ok(Some(task)) =
1379 registry.get(cancel_session_id, &task_id_str).await
1380 && task.cancel_requested_at.is_some()
1381 {
1382 break;
1383 }
1384 }
1385 };
1386 tokio::select! {
1387 result = tool_fut => result,
1388 () = watch_fut => {
1389 Err(ToolExecutionResult::ToolError(
1391 BACKGROUND_CANCEL_SENTINEL.to_string(),
1392 ))
1393 }
1394 }
1395 }
1396 _ => match tool_for_task.as_background_executable() {
1398 Some(background_tool) => {
1399 background_tool
1400 .execute_background(tool_args, background_context, sink.clone())
1401 .await
1402 }
1403 None => Err(ToolExecutionResult::tool_error(format!(
1404 "Tool declared background support but has no background executor: {}",
1405 tool_name_for_task
1406 ))),
1407 },
1408 };
1409
1410 let finalize_result = if is_canceled_outcome(&outcome) {
1413 sink.finalize_canceled().await
1414 } else {
1415 sink.finalize(outcome).await
1416 };
1417 if let Err(err) = finalize_result {
1418 tracing::warn!(
1419 run_id = run_id_for_task,
1420 error = %err,
1421 "Background run finalization failed"
1422 );
1423 }
1424 });
1425
1426 ToolExecutionResult::success(json!({
1427 "run_id": run_id,
1428 "resource_id": run_id,
1429 "task_id": task_id,
1430 "title": title,
1431 "tool": tool_name,
1432 "status": "running",
1433 "signal_on_completion": signal_on_completion,
1434 "artifact_dir": artifact_dir,
1435 "log_path": log_path,
1436 "result_path": result_path
1437 }))
1438 }
1439
1440 fn requires_context(&self) -> bool {
1441 true
1442 }
1443}
1444
1445#[derive(Debug, Default)]
1446struct SessionBackgroundState {
1447 status_text: String,
1448 progress: Option<BackgroundProgress>,
1449 output_tail: String,
1450 output_log: String,
1451 output_log_chars: usize,
1452 output_log_truncated: bool,
1453}
1454
1455const MAX_BACKGROUND_OUTPUT_LOG_CHARS: usize = 256 * 1024;
1456
1457struct SessionBackgroundSink {
1458 context: ToolContext,
1459 run_id: String,
1460 display_name: String,
1461 tool_name: String,
1462 log_path: String,
1463 result_path: String,
1464 signal_on_completion: bool,
1465 task_id: Option<String>,
1467 state: tokio::sync::Mutex<SessionBackgroundState>,
1468}
1469
1470impl SessionBackgroundSink {
1471 #[allow(clippy::too_many_arguments)]
1472 fn new(
1473 context: ToolContext,
1474 run_id: String,
1475 display_name: String,
1476 tool_name: String,
1477 log_path: String,
1478 result_path: String,
1479 signal_on_completion: bool,
1480 task_id: Option<String>,
1481 ) -> Self {
1482 Self {
1483 context,
1484 run_id,
1485 display_name,
1486 tool_name,
1487 log_path,
1488 result_path,
1489 signal_on_completion,
1490 task_id,
1491 state: tokio::sync::Mutex::new(SessionBackgroundState {
1492 status_text: "Queued".to_string(),
1493 ..Default::default()
1494 }),
1495 }
1496 }
1497
1498 async fn mirror_task(&self, update: crate::session_task::SessionTaskUpdate) {
1500 let (Some(registry), Some(task_id)) = (&self.context.session_task_registry, &self.task_id)
1501 else {
1502 return;
1503 };
1504 let _ = registry
1505 .update(self.context.session_id, task_id, update)
1506 .await;
1507 }
1508
1509 async fn finalize_canceled(&self) -> Result<()> {
1510 let output_log = {
1511 let state = self.state.lock().await;
1512 let mut log = Self::final_output_log(&state);
1513 log.push_str("\nCanceled by request.\n");
1514 log
1515 };
1516 self.write_text_file(&self.log_path, &output_log).await?;
1517 let result_json = serde_json::to_string_pretty(&serde_json::json!({"status": "canceled"}))
1518 .unwrap_or_else(|_| r#"{"status":"canceled"}"#.to_string());
1519 self.write_text_file(&self.result_path, &result_json)
1520 .await?;
1521
1522 let mut state = self.state.lock().await;
1523 state.status_text = "Canceled".to_string();
1524 drop(state);
1525
1526 self.mirror_task(crate::session_task::SessionTaskUpdate {
1527 state: Some(crate::session_task::SessionTaskState::Canceled),
1528 summary: Some("Canceled by request.".to_string()),
1529 result_path: Some(self.result_path.clone()),
1530 ..Default::default()
1531 })
1532 .await;
1533 if self.signal_on_completion {
1534 self.signal_session("canceled", "Canceled by request.")
1535 .await?;
1536 }
1537 Ok(())
1538 }
1539
1540 async fn finalize(
1541 &self,
1542 outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult>,
1543 ) -> Result<()> {
1544 match outcome {
1545 Ok(outcome) => {
1546 let output_log = if let Some(raw_output) = &outcome.raw_output {
1547 raw_output.clone()
1548 } else {
1549 let state = self.state.lock().await;
1550 Self::final_output_log(&state)
1551 };
1552 self.write_text_file(&self.log_path, &output_log).await?;
1553 let result_json = serde_json::to_string_pretty(&outcome.result)
1554 .unwrap_or_else(|_| outcome.result.to_string());
1555 self.write_text_file(&self.result_path, &result_json)
1556 .await?;
1557
1558 let mut state = self.state.lock().await;
1559 state.status_text = "Completed".to_string();
1560 drop(state);
1561 self.mirror_task(crate::session_task::SessionTaskUpdate {
1562 state: Some(crate::session_task::SessionTaskState::Succeeded),
1563 summary: Some(outcome.summary.clone()),
1564 result_path: Some(self.result_path.clone()),
1565 ..Default::default()
1566 })
1567 .await;
1568 if self.signal_on_completion {
1569 self.signal_session("completed", &outcome.summary).await?;
1570 }
1571 }
1572 Err(err) => {
1573 let message = match err {
1574 ToolExecutionResult::ToolError(msg) => msg,
1575 ToolExecutionResult::InternalError(inner) => inner.message,
1576 ToolExecutionResult::ConnectionRequired { provider } => {
1577 format!("Background tool requires connection setup: {provider}")
1578 }
1579 ToolExecutionResult::Success(_)
1580 | ToolExecutionResult::SuccessWithImages { .. } => {
1581 "Background run ended unexpectedly".to_string()
1582 }
1583 };
1584 let output_log = {
1585 let state = self.state.lock().await;
1586 Self::final_output_log(&state)
1587 };
1588 self.write_text_file(&self.log_path, &output_log).await?;
1589 let error_json = serde_json::to_string_pretty(&json!({
1590 "status": "failed",
1591 "error": &message,
1592 }))
1593 .unwrap_or_else(|_| {
1594 json!({
1595 "status": "failed",
1596 "error": &message,
1597 })
1598 .to_string()
1599 });
1600 self.write_text_file(&self.result_path, &error_json).await?;
1601 let mut state = self.state.lock().await;
1602 state.status_text = "Failed".to_string();
1603 drop(state);
1604 self.mirror_task(crate::session_task::SessionTaskUpdate {
1605 state: Some(crate::session_task::SessionTaskState::Failed),
1606 summary: Some(message.clone()),
1607 result_path: Some(self.result_path.clone()),
1608 error: Some(crate::session_task::TaskError {
1609 kind: "error".to_string(),
1610 message: message.clone(),
1611 }),
1612 ..Default::default()
1613 })
1614 .await;
1615 if self.signal_on_completion {
1616 self.signal_session("failed", &message).await?;
1617 }
1618 }
1619 }
1620
1621 Ok(())
1622 }
1623
1624 async fn signal_session(&self, status: &str, summary: &str) -> Result<()> {
1625 let Some(platform_store) = &self.context.platform_store else {
1626 return Ok(());
1627 };
1628 let message = format!(
1629 "Background run {status}.\n- run_id: {}\n- title: {}\n- tool: {}\n- summary: {}\n- result_path: {}\n- log_path: {}",
1630 self.run_id,
1631 self.display_name,
1632 self.tool_name,
1633 summary,
1634 self.result_path,
1635 self.log_path
1636 );
1637 platform_store
1638 .send_message(self.context.session_id, &message)
1639 .await
1640 }
1641
1642 async fn write_text_file(&self, path: &str, content: &str) -> Result<()> {
1643 let file_store = self.context.file_store.as_ref().ok_or_else(|| {
1644 anyhow::anyhow!(
1645 "background run {} cannot persist artifact {} because no session file store is configured",
1646 self.run_id,
1647 path
1648 )
1649 })?;
1650
1651 ensure_directory(file_store.as_ref(), self.context.session_id, "/.background").await?;
1652 let run_dir = format!("/.background/{}", self.run_id);
1653 ensure_directory(file_store.as_ref(), self.context.session_id, &run_dir).await?;
1654 file_store
1655 .write_file(self.context.session_id, path, content, "text")
1656 .await?;
1657 Ok(())
1658 }
1659}
1660
1661#[async_trait]
1662impl BackgroundEventSink for SessionBackgroundSink {
1663 async fn status(&self, message: &str) -> Result<()> {
1664 let mut state = self.state.lock().await;
1665 state.status_text = message.to_string();
1666 drop(state);
1667 self.mirror_task(crate::session_task::SessionTaskUpdate {
1668 state_detail: Some(message.to_string()),
1669 ..Default::default()
1670 })
1671 .await;
1672 Ok(())
1673 }
1674
1675 async fn output(&self, stream: &str, delta: &str) -> Result<()> {
1676 let mut state = self.state.lock().await;
1677 if !delta.is_empty() {
1678 let prefix = format!("[{stream}] ");
1679 state.output_tail.push_str(&prefix);
1680 state.output_tail.push_str(delta);
1681 Self::append_to_output_log(&mut state, &prefix, delta);
1682 if state.output_tail.chars().count() > 2048 {
1683 state.output_tail = state
1684 .output_tail
1685 .chars()
1686 .rev()
1687 .take(2048)
1688 .collect::<Vec<_>>()
1689 .into_iter()
1690 .rev()
1691 .collect();
1692 }
1693 }
1694 Ok(())
1695 }
1696
1697 async fn progress(&self, progress: BackgroundProgress) -> Result<()> {
1698 let mut state = self.state.lock().await;
1699 state.progress = Some(progress.clone());
1700 drop(state);
1701 self.mirror_task(crate::session_task::SessionTaskUpdate {
1702 progress: Some(progress),
1703 ..Default::default()
1704 })
1705 .await;
1706 Ok(())
1707 }
1708}
1709
1710impl SessionBackgroundSink {
1711 fn append_to_output_log(state: &mut SessionBackgroundState, prefix: &str, delta: &str) {
1712 if state.output_log_chars >= MAX_BACKGROUND_OUTPUT_LOG_CHARS {
1713 state.output_log_truncated = true;
1714 return;
1715 }
1716
1717 let chunk = format!("{prefix}{delta}");
1718 let remaining = MAX_BACKGROUND_OUTPUT_LOG_CHARS - state.output_log_chars;
1719 let chunk_chars = chunk.chars().count();
1720
1721 if chunk_chars <= remaining {
1722 state.output_log.push_str(&chunk);
1723 state.output_log_chars += chunk_chars;
1724 return;
1725 }
1726
1727 let truncated_chunk: String = chunk.chars().take(remaining).collect();
1728 state.output_log.push_str(&truncated_chunk);
1729 state.output_log_chars += truncated_chunk.chars().count();
1730 state.output_log_truncated = true;
1731 }
1732
1733 fn final_output_log(state: &SessionBackgroundState) -> String {
1734 if !state.output_log_truncated {
1735 return state.output_log.clone();
1736 }
1737
1738 format!(
1739 "{}\n[system] background output truncated at {} characters\n",
1740 state.output_log, MAX_BACKGROUND_OUTPUT_LOG_CHARS
1741 )
1742 }
1743}
1744
1745const BACKGROUND_CANCEL_SENTINEL: &str = "__everruns_background_cancel__";
1749
1750fn is_canceled_outcome(
1754 outcome: &std::result::Result<BackgroundOutcome, ToolExecutionResult>,
1755) -> bool {
1756 matches!(outcome, Err(ToolExecutionResult::ToolError(msg)) if msg == BACKGROUND_CANCEL_SENTINEL)
1757}
1758
1759pub(crate) async fn reattach_background_run(
1775 task: &crate::session_task::SessionTask,
1776 context: &crate::traits::ToolContext,
1777) -> crate::error::Result<()> {
1778 if context.file_store.is_none() {
1781 return Err(crate::error::AgentLoopError::tool(
1782 "file store not available; cannot re-attach background run",
1783 ));
1784 }
1785 if context.session_task_registry.is_none() {
1786 return Err(crate::error::AgentLoopError::tool(
1787 "task registry not available; cannot re-attach background run",
1788 ));
1789 }
1790
1791 let tool_name: String = task
1792 .spec
1793 .get("tool")
1794 .and_then(|v| v.as_str())
1795 .filter(|s| !s.is_empty())
1796 .map(str::to_owned)
1797 .ok_or_else(|| {
1798 crate::error::AgentLoopError::tool(
1799 "background_tool spec missing 'tool' field; cannot re-attach",
1800 )
1801 })?;
1802
1803 let tool_args = task
1804 .spec
1805 .get("arguments")
1806 .cloned()
1807 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
1808
1809 let registry = std::sync::Arc::new(ToolRegistry::with_defaults());
1810
1811 let Some(tool) = registry.get(&tool_name).cloned() else {
1812 return Err(crate::error::AgentLoopError::tool(format!(
1813 "tool '{tool_name}' not found in built-in registry; cannot re-attach"
1814 )));
1815 };
1816
1817 if tool.as_background_executable().is_none() {
1818 return Err(crate::error::AgentLoopError::tool(format!(
1819 "tool '{tool_name}' does not support background execution; cannot re-attach"
1820 )));
1821 }
1822
1823 let hints = tool.hints();
1826 if !hints.idempotent.unwrap_or(false) && !hints.readonly.unwrap_or(false) {
1827 return Err(crate::error::AgentLoopError::tool(format!(
1828 "tool '{tool_name}' is not idempotent or readonly; re-attach declined",
1829 )));
1830 }
1831
1832 let background_run_permit = ACTIVE_BACKGROUND_RUNS_PER_WORKER
1835 .try_acquire()
1836 .map_err(|_| {
1837 crate::error::AgentLoopError::tool(
1838 "worker background run limit reached; re-attach deferred",
1839 )
1840 })?;
1841 let session_run_permit =
1842 try_acquire_session_background_permit(task.session_id).map_err(|_| {
1843 crate::error::AgentLoopError::tool(
1844 "session background run limit reached; re-attach deferred",
1845 )
1846 })?;
1847
1848 let signal_on_completion = task
1851 .spec
1852 .get("signal_on_completion")
1853 .and_then(|v| v.as_bool())
1854 .unwrap_or(true);
1855
1856 let run_id = format!("bg_{}", uuid::Uuid::now_v7().simple());
1857 let artifact_dir = format!("/.background/{run_id}");
1858 let log_path = format!("{artifact_dir}/output.log");
1859 let result_path = format!("{artifact_dir}/result.json");
1860
1861 let task_id = task.id.clone();
1862 let task_attempt = task.attempt;
1863 let session_id = task.session_id;
1864
1865 let sink_context = context.clone().with_tool_registry(registry);
1866 let sink = std::sync::Arc::new(SessionBackgroundSink::new(
1867 sink_context.clone(),
1868 run_id.clone(),
1869 task.display_name.clone(),
1870 tool_name.to_string(),
1871 log_path,
1872 result_path,
1873 signal_on_completion,
1874 Some(task_id.clone()),
1875 ));
1876
1877 let cancel_registry = context.session_task_registry.clone();
1878 let run_id_for_log = run_id.clone();
1879
1880 tokio::spawn(async move {
1881 let _background_run_permit = background_run_permit;
1883 let _session_run_permit = session_run_permit;
1884 let _ = sink.status("Re-attaching").await;
1885
1886 let outcome: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
1887 match (cancel_registry.as_ref(), Some(task_id.as_str())) {
1888 (Some(registry), Some(task_id_str)) => {
1889 let registry = registry.clone();
1890 let task_id_str = task_id_str.to_string();
1891 let tool_fut = async {
1892 match tool.as_background_executable() {
1893 Some(bg) => {
1894 bg.execute_background(tool_args, sink_context.clone(), sink.clone())
1895 .await
1896 }
1897 None => Err(ToolExecutionResult::tool_error(format!(
1898 "tool '{tool_name}' lost background support during re-attach"
1899 ))),
1900 }
1901 };
1902 let watch_fut = async {
1903 loop {
1904 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1905 let _ = registry
1906 .update(
1907 session_id,
1908 &task_id_str,
1909 crate::session_task::SessionTaskUpdate {
1910 heartbeat_at: Some(chrono::Utc::now()),
1911 expected_attempt: Some(task_attempt),
1912 ..Default::default()
1913 },
1914 )
1915 .await;
1916 if let Ok(Some(t)) = registry.get(session_id, &task_id_str).await
1917 && t.cancel_requested_at.is_some()
1918 {
1919 break;
1920 }
1921 }
1922 };
1923 tokio::select! {
1924 result = tool_fut => result,
1925 () = watch_fut => Err(ToolExecutionResult::ToolError(
1926 BACKGROUND_CANCEL_SENTINEL.to_string(),
1927 )),
1928 }
1929 }
1930 _ => match tool.as_background_executable() {
1931 Some(bg) => {
1932 bg.execute_background(tool_args, sink_context, sink.clone())
1933 .await
1934 }
1935 None => Err(ToolExecutionResult::tool_error(format!(
1936 "tool '{tool_name}' lost background support during re-attach"
1937 ))),
1938 },
1939 };
1940
1941 let finalize_result = if is_canceled_outcome(&outcome) {
1942 sink.finalize_canceled().await
1943 } else {
1944 sink.finalize(outcome).await
1945 };
1946 if let Err(err) = finalize_result {
1947 tracing::warn!(
1948 run_id = run_id_for_log,
1949 error = %err,
1950 "Background run re-attach finalization failed"
1951 );
1952 }
1953 });
1954
1955 Ok(())
1956}
1957
1958async fn ensure_directory(
1959 file_store: &dyn crate::traits::SessionFileSystem,
1960 session_id: crate::SessionId,
1961 path: &str,
1962) -> Result<()> {
1963 if let Some(entry) = file_store.stat_file(session_id, path).await? {
1964 if entry.is_directory {
1965 return Ok(());
1966 }
1967 return Err(anyhow::anyhow!("path exists but is not a directory: {path}").into());
1968 }
1969 let _ = file_store.create_directory(session_id, path).await?;
1970 Ok(())
1971}
1972
1973pub struct FailingTool {
1975 error_message: String,
1976 use_internal_error: bool,
1977}
1978
1979impl FailingTool {
1980 pub fn with_tool_error(message: impl Into<String>) -> Self {
1982 Self {
1983 error_message: message.into(),
1984 use_internal_error: false,
1985 }
1986 }
1987
1988 pub fn with_internal_error(message: impl Into<String>) -> Self {
1990 Self {
1991 error_message: message.into(),
1992 use_internal_error: true,
1993 }
1994 }
1995}
1996
1997impl Default for FailingTool {
1998 fn default() -> Self {
1999 Self::with_tool_error("Tool execution failed")
2000 }
2001}
2002
2003#[async_trait]
2004impl Tool for FailingTool {
2005 fn name(&self) -> &str {
2006 "failing_tool"
2007 }
2008
2009 fn display_name(&self) -> Option<&str> {
2010 Some("Failing Tool")
2011 }
2012
2013 fn description(&self) -> &str {
2014 "A tool that always fails (for testing error handling)"
2015 }
2016
2017 fn parameters_schema(&self) -> Value {
2018 serde_json::json!({
2019 "type": "object",
2020 "properties": {},
2021 "additionalProperties": false
2022 })
2023 }
2024
2025 fn hints(&self) -> ToolHints {
2026 ToolHints::default()
2027 .with_readonly(true)
2028 .with_idempotent(true)
2029 }
2030
2031 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2032 if self.use_internal_error {
2033 ToolExecutionResult::internal_error_msg(&self.error_message)
2034 } else {
2035 ToolExecutionResult::tool_error(&self.error_message)
2036 }
2037 }
2038}
2039
2040#[cfg(test)]
2045mod tests {
2046 use super::*;
2047 use crate::capabilities::GetCurrentTimeTool;
2048 use crate::platform_store::PlatformStore;
2049 use crate::session_file::{FileInfo, FileStat, SessionFile};
2050 use crate::session_task::SessionTaskRegistry;
2051 use crate::traits::{SessionFileSystem, SessionScheduleStore};
2052 use crate::typed_id::{HarnessId, SessionId};
2053 use crate::{AgentId, KeyInfo, PlatformMessage, SecretInfo};
2054 use async_trait::async_trait;
2055 use std::sync::{
2056 Arc as StdArc, Mutex,
2057 atomic::{AtomicBool, Ordering},
2058 };
2059
2060 #[derive(Default)]
2061 struct TestBackgroundTool;
2062
2063 #[async_trait]
2064 impl BackgroundExecutableTool for TestBackgroundTool {
2065 async fn execute_background(
2066 &self,
2067 arguments: Value,
2068 _context: ToolContext,
2069 sink: Arc<dyn BackgroundEventSink>,
2070 ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2071 sink.status("Waiting for test result")
2072 .await
2073 .map_err(ToolExecutionResult::internal_error)?;
2074 sink.output("stdout", "hello from background")
2075 .await
2076 .map_err(ToolExecutionResult::internal_error)?;
2077 sink.progress(BackgroundProgress {
2078 current: Some(1),
2079 total: Some(1),
2080 unit: Some("step".to_string()),
2081 label: Some("done".to_string()),
2082 })
2083 .await
2084 .map_err(ToolExecutionResult::internal_error)?;
2085
2086 Ok(BackgroundOutcome {
2087 summary: arguments["summary"].as_str().unwrap_or("done").to_string(),
2088 result: json!({"ok": true}),
2089 raw_output: None,
2090 })
2091 }
2092 }
2093
2094 #[async_trait]
2095 impl Tool for TestBackgroundTool {
2096 fn name(&self) -> &str {
2097 "test_background"
2098 }
2099
2100 fn display_name(&self) -> Option<&str> {
2101 Some("Test Background")
2102 }
2103
2104 fn description(&self) -> &str {
2105 "test tool"
2106 }
2107
2108 fn parameters_schema(&self) -> Value {
2109 json!({
2110 "type": "object",
2111 "properties": {
2112 "summary": { "type": "string" }
2113 }
2114 })
2115 }
2116
2117 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2118 ToolExecutionResult::tool_error("foreground unsupported")
2119 }
2120
2121 fn hints(&self) -> ToolHints {
2122 ToolHints::default().with_supports_background(true)
2123 }
2124
2125 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2126 Some(self)
2127 }
2128 }
2129
2130 #[derive(Default)]
2131 struct TestFailingBackgroundTool;
2132
2133 #[async_trait]
2134 impl BackgroundExecutableTool for TestFailingBackgroundTool {
2135 async fn execute_background(
2136 &self,
2137 _arguments: Value,
2138 _context: ToolContext,
2139 sink: Arc<dyn BackgroundEventSink>,
2140 ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2141 sink.status("Running failing test")
2142 .await
2143 .map_err(ToolExecutionResult::internal_error)?;
2144 sink.output("stderr", "background failed")
2145 .await
2146 .map_err(ToolExecutionResult::internal_error)?;
2147 Err(ToolExecutionResult::tool_error("boom"))
2148 }
2149 }
2150
2151 #[async_trait]
2152 impl Tool for TestFailingBackgroundTool {
2153 fn name(&self) -> &str {
2154 "test_background_fail"
2155 }
2156
2157 fn display_name(&self) -> Option<&str> {
2158 Some("Test Background Fail")
2159 }
2160
2161 fn description(&self) -> &str {
2162 "failing background test tool"
2163 }
2164
2165 fn parameters_schema(&self) -> Value {
2166 json!({
2167 "type": "object",
2168 "properties": {}
2169 })
2170 }
2171
2172 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2173 ToolExecutionResult::tool_error("foreground unsupported")
2174 }
2175
2176 fn hints(&self) -> ToolHints {
2177 ToolHints::default().with_supports_background(true)
2178 }
2179
2180 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2181 Some(self)
2182 }
2183 }
2184
2185 #[derive(Default)]
2186 struct TestLargeOutputBackgroundTool;
2187
2188 #[async_trait]
2189 impl BackgroundExecutableTool for TestLargeOutputBackgroundTool {
2190 async fn execute_background(
2191 &self,
2192 _arguments: Value,
2193 _context: ToolContext,
2194 sink: Arc<dyn BackgroundEventSink>,
2195 ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2196 let large_chunk = "x".repeat(MAX_BACKGROUND_OUTPUT_LOG_CHARS + 4096);
2197 sink.output("stdout", &large_chunk)
2198 .await
2199 .map_err(ToolExecutionResult::internal_error)?;
2200 Ok(BackgroundOutcome {
2201 summary: "large output complete".to_string(),
2202 result: json!({"ok": true}),
2203 raw_output: None,
2204 })
2205 }
2206 }
2207
2208 #[async_trait]
2209 impl Tool for TestLargeOutputBackgroundTool {
2210 fn name(&self) -> &str {
2211 "test_background_large_output"
2212 }
2213
2214 fn display_name(&self) -> Option<&str> {
2215 Some("Test Background Large Output")
2216 }
2217
2218 fn description(&self) -> &str {
2219 "background test tool with huge output"
2220 }
2221
2222 fn parameters_schema(&self) -> Value {
2223 json!({
2224 "type": "object",
2225 "properties": {}
2226 })
2227 }
2228
2229 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2230 ToolExecutionResult::tool_error("foreground unsupported")
2231 }
2232
2233 fn hints(&self) -> ToolHints {
2234 ToolHints::default().with_supports_background(true)
2235 }
2236
2237 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2238 Some(self)
2239 }
2240 }
2241
2242 struct BlockingBackgroundTool {
2243 release: StdArc<AtomicBool>,
2244 }
2245
2246 #[async_trait]
2247 impl BackgroundExecutableTool for BlockingBackgroundTool {
2248 async fn execute_background(
2249 &self,
2250 _arguments: Value,
2251 _context: ToolContext,
2252 sink: Arc<dyn BackgroundEventSink>,
2253 ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
2254 sink.status("Blocking until released")
2255 .await
2256 .map_err(ToolExecutionResult::internal_error)?;
2257 while !self.release.load(Ordering::SeqCst) {
2258 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2259 }
2260 Ok(BackgroundOutcome {
2261 summary: "released".to_string(),
2262 result: json!({"ok": true}),
2263 raw_output: None,
2264 })
2265 }
2266 }
2267
2268 #[async_trait]
2269 impl Tool for BlockingBackgroundTool {
2270 fn name(&self) -> &str {
2271 "test_background_blocking"
2272 }
2273
2274 fn display_name(&self) -> Option<&str> {
2275 Some("Test Background Blocking")
2276 }
2277
2278 fn description(&self) -> &str {
2279 "background test tool that waits for test release"
2280 }
2281
2282 fn parameters_schema(&self) -> Value {
2283 json!({
2284 "type": "object",
2285 "properties": {}
2286 })
2287 }
2288
2289 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2290 ToolExecutionResult::tool_error("foreground unsupported")
2291 }
2292
2293 fn hints(&self) -> ToolHints {
2294 ToolHints::default().with_supports_background(true)
2295 }
2296
2297 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
2298 Some(self)
2299 }
2300 }
2301
2302 #[derive(Default)]
2303 struct TestFileStore {
2304 files: Mutex<HashMap<String, SessionFile>>,
2305 }
2306
2307 #[async_trait]
2308 impl crate::traits::SessionFileSystem for TestFileStore {
2309 async fn read_file(
2310 &self,
2311 _session_id: SessionId,
2312 path: &str,
2313 ) -> crate::Result<Option<SessionFile>> {
2314 Ok(self.files.lock().unwrap().get(path).cloned())
2315 }
2316
2317 async fn write_file(
2318 &self,
2319 session_id: SessionId,
2320 path: &str,
2321 content: &str,
2322 encoding: &str,
2323 ) -> crate::Result<SessionFile> {
2324 let now = chrono::Utc::now();
2325 let file = SessionFile {
2326 id: uuid::Uuid::now_v7(),
2327 session_id: session_id.uuid(),
2328 path: path.to_string(),
2329 name: FileInfo::name_from_path(path),
2330 content: Some(content.to_string()),
2331 encoding: encoding.to_string(),
2332 is_directory: false,
2333 is_readonly: false,
2334 size_bytes: content.len() as i64,
2335 created_at: now,
2336 updated_at: now,
2337 };
2338 self.files
2339 .lock()
2340 .unwrap()
2341 .insert(path.to_string(), file.clone());
2342 Ok(file)
2343 }
2344
2345 async fn delete_file(
2346 &self,
2347 _session_id: SessionId,
2348 _path: &str,
2349 _recursive: bool,
2350 ) -> crate::Result<bool> {
2351 Ok(false)
2352 }
2353
2354 async fn list_directory(
2355 &self,
2356 _session_id: SessionId,
2357 _path: &str,
2358 ) -> crate::Result<Vec<FileInfo>> {
2359 Ok(Vec::new())
2360 }
2361
2362 async fn stat_file(
2363 &self,
2364 _session_id: SessionId,
2365 path: &str,
2366 ) -> crate::Result<Option<FileStat>> {
2367 let file = self.files.lock().unwrap().get(path).cloned();
2368 Ok(file.map(|entry| FileStat {
2369 path: entry.path,
2370 name: entry.name,
2371 is_directory: entry.is_directory,
2372 is_readonly: entry.is_readonly,
2373 size_bytes: entry.size_bytes,
2374 created_at: entry.created_at,
2375 updated_at: entry.updated_at,
2376 }))
2377 }
2378
2379 async fn grep_files(
2380 &self,
2381 _session_id: SessionId,
2382 _pattern: &str,
2383 _path_pattern: Option<&str>,
2384 ) -> crate::Result<Vec<crate::session_file::GrepMatch>> {
2385 Ok(Vec::new())
2386 }
2387
2388 async fn create_directory(
2389 &self,
2390 session_id: SessionId,
2391 path: &str,
2392 ) -> crate::Result<FileInfo> {
2393 let now = chrono::Utc::now();
2394 let id = uuid::Uuid::now_v7();
2395 let dir = SessionFile {
2396 id,
2397 session_id: session_id.uuid(),
2398 path: path.to_string(),
2399 name: FileInfo::name_from_path(path),
2400 content: None,
2401 encoding: "text".to_string(),
2402 is_directory: true,
2403 is_readonly: false,
2404 size_bytes: 0,
2405 created_at: now,
2406 updated_at: now,
2407 };
2408 self.files.lock().unwrap().insert(path.to_string(), dir);
2409 Ok(FileInfo {
2410 id,
2411 session_id: session_id.uuid(),
2412 path: path.to_string(),
2413 name: FileInfo::name_from_path(path),
2414 is_directory: true,
2415 is_readonly: false,
2416 size_bytes: 0,
2417 created_at: now,
2418 updated_at: now,
2419 })
2420 }
2421 }
2422
2423 #[derive(Default)]
2424 struct TestPlatformStore {
2425 sent_messages: Mutex<Vec<String>>,
2426 }
2427
2428 #[async_trait]
2429 impl PlatformStore for TestPlatformStore {
2430 async fn list_harnesses(&self) -> crate::Result<Vec<crate::Harness>> {
2431 Ok(Vec::new())
2432 }
2433 async fn get_harness(&self, _id: HarnessId) -> crate::Result<Option<crate::Harness>> {
2434 Ok(None)
2435 }
2436 async fn create_harness(
2437 &self,
2438 _name: &str,
2439 _display_name: Option<&str>,
2440 _description: Option<&str>,
2441 _system_prompt: Option<&str>,
2442 _parent_harness_id: Option<HarnessId>,
2443 _capabilities: &[String],
2444 ) -> crate::Result<crate::Harness> {
2445 unreachable!()
2446 }
2447 async fn update_harness(
2448 &self,
2449 _id: HarnessId,
2450 _name: Option<&str>,
2451 _display_name: Option<&str>,
2452 _description: Option<&str>,
2453 _system_prompt: Option<&str>,
2454 _parent_harness_id: Option<Option<HarnessId>>,
2455 ) -> crate::Result<crate::Harness> {
2456 unreachable!()
2457 }
2458 async fn delete_harness(&self, _id: HarnessId) -> crate::Result<()> {
2459 Ok(())
2460 }
2461 async fn copy_harness(
2462 &self,
2463 _id: HarnessId,
2464 _new_name: Option<&str>,
2465 ) -> crate::Result<crate::Harness> {
2466 unreachable!()
2467 }
2468 async fn list_agents(&self) -> crate::Result<Vec<crate::Agent>> {
2469 Ok(Vec::new())
2470 }
2471 async fn get_agent_by_id(&self, _id: AgentId) -> crate::Result<Option<crate::Agent>> {
2472 Ok(None)
2473 }
2474 async fn create_agent(
2475 &self,
2476 _name: &str,
2477 _display_name: Option<&str>,
2478 _description: Option<&str>,
2479 _system_prompt: &str,
2480 _capabilities: &[String],
2481 ) -> crate::Result<crate::Agent> {
2482 unreachable!()
2483 }
2484 async fn update_agent(
2485 &self,
2486 _id: AgentId,
2487 _name: Option<&str>,
2488 _display_name: Option<&str>,
2489 _description: Option<&str>,
2490 _system_prompt: Option<&str>,
2491 ) -> crate::Result<crate::Agent> {
2492 unreachable!()
2493 }
2494 async fn delete_agent(&self, _id: AgentId) -> crate::Result<()> {
2495 Ok(())
2496 }
2497 async fn list_apps(
2498 &self,
2499 _search: Option<&str>,
2500 _include_archived: bool,
2501 ) -> crate::Result<Vec<crate::App>> {
2502 Ok(Vec::new())
2503 }
2504 async fn get_app(&self, _id: crate::AppId) -> crate::Result<Option<crate::App>> {
2505 Ok(None)
2506 }
2507 async fn create_app(
2508 &self,
2509 _name: &str,
2510 _description: Option<&str>,
2511 _harness_id: HarnessId,
2512 _agent_id: Option<AgentId>,
2513 _agent_identity_id: Option<crate::AgentIdentityId>,
2514 _channel_type: Option<crate::ChannelType>,
2515 _channel_config: Option<&serde_json::Value>,
2516 ) -> crate::Result<crate::App> {
2517 unreachable!()
2518 }
2519 async fn update_app(
2520 &self,
2521 _id: crate::AppId,
2522 _name: Option<&str>,
2523 _description: Option<&str>,
2524 _harness_id: Option<HarnessId>,
2525 _agent_id: Option<AgentId>,
2526 _agent_identity_id: Option<Option<crate::AgentIdentityId>>,
2527 ) -> crate::Result<crate::App> {
2528 unreachable!()
2529 }
2530 async fn delete_app(&self, _id: crate::AppId) -> crate::Result<()> {
2531 Ok(())
2532 }
2533 async fn destroy_app(&self, _id: crate::AppId) -> crate::Result<()> {
2534 Ok(())
2535 }
2536 async fn publish_app(&self, _id: crate::AppId) -> crate::Result<crate::App> {
2537 unreachable!()
2538 }
2539 async fn unpublish_app(&self, _id: crate::AppId) -> crate::Result<crate::App> {
2540 unreachable!()
2541 }
2542 async fn add_app_channel(
2543 &self,
2544 _app_id: crate::AppId,
2545 _channel_type: crate::ChannelType,
2546 _channel_config: Option<&serde_json::Value>,
2547 _enabled: Option<bool>,
2548 ) -> crate::Result<crate::AppChannel> {
2549 unreachable!()
2550 }
2551 async fn update_app_channel(
2552 &self,
2553 _app_id: crate::AppId,
2554 _channel_id: crate::AppChannelId,
2555 _channel_type: Option<crate::ChannelType>,
2556 _channel_config: Option<&serde_json::Value>,
2557 _enabled: Option<bool>,
2558 ) -> crate::Result<crate::AppChannel> {
2559 unreachable!()
2560 }
2561 async fn delete_app_channel(
2562 &self,
2563 _app_id: crate::AppId,
2564 _channel_id: crate::AppChannelId,
2565 ) -> crate::Result<()> {
2566 Ok(())
2567 }
2568 async fn list_sessions(
2569 &self,
2570 _limit: Option<usize>,
2571 _agent_id: Option<AgentId>,
2572 ) -> crate::Result<Vec<crate::Session>> {
2573 Ok(Vec::new())
2574 }
2575 async fn create_session(
2576 &self,
2577 _harness_id: HarnessId,
2578 _agent_id: Option<AgentId>,
2579 _title: Option<&str>,
2580 _locale: Option<&str>,
2581 _blueprint_id: Option<&str>,
2582 _blueprint_config: Option<&serde_json::Value>,
2583 _parent_session_id: Option<SessionId>,
2584 ) -> crate::Result<crate::Session> {
2585 unreachable!()
2586 }
2587 async fn get_session_by_id(&self, _id: SessionId) -> crate::Result<Option<crate::Session>> {
2588 Ok(None)
2589 }
2590 async fn get_session_context_report(
2591 &self,
2592 id: SessionId,
2593 ) -> crate::Result<crate::SessionContextReport> {
2594 Ok(crate::SessionContextReport {
2595 session_id: id.to_string(),
2596 model: "llmsim".to_string(),
2597 context_window_tokens: None,
2598 estimated_input_tokens: 0,
2599 sections: vec![],
2600 contributions: vec![],
2601 cumulative_usage: None,
2602 })
2603 }
2604 async fn delete_session(&self, _id: SessionId) -> crate::Result<()> {
2605 Ok(())
2606 }
2607 async fn send_message(&self, _session_id: SessionId, content: &str) -> crate::Result<()> {
2608 self.sent_messages.lock().unwrap().push(content.to_string());
2609 Ok(())
2610 }
2611 async fn get_messages(
2612 &self,
2613 _session_id: SessionId,
2614 _limit: Option<usize>,
2615 ) -> crate::Result<Vec<PlatformMessage>> {
2616 Ok(Vec::new())
2617 }
2618 async fn wait_for_idle(
2619 &self,
2620 _session_id: SessionId,
2621 _timeout_secs: Option<u64>,
2622 ) -> crate::Result<String> {
2623 Ok("idle".to_string())
2624 }
2625 async fn list_capabilities(
2626 &self,
2627 _search: Option<&str>,
2628 ) -> crate::Result<Vec<crate::CapabilityInfo>> {
2629 Ok(Vec::new())
2630 }
2631 fn base_url(&self) -> &str {
2632 "http://localhost:9300"
2633 }
2634 }
2635
2636 #[derive(Default)]
2637 struct NoopStorageStore;
2638
2639 #[derive(Default)]
2640 struct TestScheduleStore {
2641 schedules: Mutex<Vec<crate::session_schedule::SessionSchedule>>,
2642 }
2643
2644 #[async_trait]
2645 impl crate::traits::SessionScheduleStore for TestScheduleStore {
2646 async fn create_schedule(
2647 &self,
2648 session_id: SessionId,
2649 description: String,
2650 cron_expression: Option<String>,
2651 scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
2652 timezone: String,
2653 ) -> crate::Result<crate::session_schedule::SessionSchedule> {
2654 let schedule = crate::session_schedule::SessionSchedule {
2655 id: crate::typed_id::ScheduleId::new(),
2656 session_id,
2657 owner_principal_id: crate::PrincipalId::from_seed(1),
2658 resolved_owner_user_id: None,
2659 owner: None,
2660 effective_owner: None,
2661 description,
2662 cron_expression: cron_expression.clone(),
2663 scheduled_at,
2664 timezone,
2665 enabled: true,
2666 schedule_type: crate::session_schedule::SessionSchedule::derive_type(
2667 &cron_expression,
2668 ),
2669 next_trigger_at: Some(chrono::Utc::now() + chrono::Duration::minutes(10)),
2670 last_triggered_at: None,
2671 trigger_count: 0,
2672 created_at: chrono::Utc::now(),
2673 updated_at: chrono::Utc::now(),
2674 };
2675 self.schedules.lock().unwrap().push(schedule.clone());
2676 Ok(schedule)
2677 }
2678
2679 async fn cancel_schedule(
2680 &self,
2681 _session_id: SessionId,
2682 schedule_id: crate::ScheduleId,
2683 ) -> crate::Result<crate::session_schedule::SessionSchedule> {
2684 let mut schedules = self.schedules.lock().unwrap();
2685 let schedule = schedules
2686 .iter_mut()
2687 .find(|schedule| schedule.id == schedule_id)
2688 .ok_or_else(|| crate::AgentLoopError::tool("Schedule not found".to_string()))?;
2689 schedule.enabled = false;
2690 Ok(schedule.clone())
2691 }
2692
2693 async fn list_schedules(
2694 &self,
2695 session_id: SessionId,
2696 ) -> crate::Result<Vec<crate::session_schedule::SessionSchedule>> {
2697 Ok(self
2698 .schedules
2699 .lock()
2700 .unwrap()
2701 .iter()
2702 .filter(|schedule| schedule.session_id == session_id)
2703 .cloned()
2704 .collect())
2705 }
2706
2707 async fn count_active_schedules(&self, session_id: SessionId) -> crate::Result<u32> {
2708 Ok(self
2709 .schedules
2710 .lock()
2711 .unwrap()
2712 .iter()
2713 .filter(|schedule| schedule.session_id == session_id && schedule.enabled)
2714 .count() as u32)
2715 }
2716
2717 async fn count_active_org_schedules(&self) -> crate::Result<u32> {
2718 Ok(self
2720 .schedules
2721 .lock()
2722 .unwrap()
2723 .iter()
2724 .filter(|schedule| schedule.enabled)
2725 .count() as u32)
2726 }
2727 }
2728
2729 #[async_trait]
2730 impl crate::traits::SessionStorageStore for NoopStorageStore {
2731 async fn set_value(
2732 &self,
2733 _session_id: SessionId,
2734 _key: &str,
2735 _value: &str,
2736 ) -> crate::Result<()> {
2737 Ok(())
2738 }
2739 async fn get_value(
2740 &self,
2741 _session_id: SessionId,
2742 _key: &str,
2743 ) -> crate::Result<Option<String>> {
2744 Ok(None)
2745 }
2746 async fn delete_value(&self, _session_id: SessionId, _key: &str) -> crate::Result<bool> {
2747 Ok(false)
2748 }
2749 async fn list_keys(&self, _session_id: SessionId) -> crate::Result<Vec<KeyInfo>> {
2750 Ok(Vec::new())
2751 }
2752 async fn set_secret(
2753 &self,
2754 _session_id: SessionId,
2755 _name: &str,
2756 _value: &str,
2757 ) -> crate::Result<()> {
2758 Ok(())
2759 }
2760 async fn get_secret(
2761 &self,
2762 _session_id: SessionId,
2763 _name: &str,
2764 ) -> crate::Result<Option<String>> {
2765 Ok(None)
2766 }
2767 async fn delete_secret(&self, _session_id: SessionId, _name: &str) -> crate::Result<bool> {
2768 Ok(false)
2769 }
2770 async fn list_secrets(&self, _session_id: SessionId) -> crate::Result<Vec<SecretInfo>> {
2771 Ok(Vec::new())
2772 }
2773 }
2774
2775 #[tokio::test]
2776 async fn test_echo_tool() {
2777 let tool = EchoTool;
2778
2779 let result = tool
2780 .execute(serde_json::json!({"message": "Hello, world!"}))
2781 .await;
2782
2783 if let ToolExecutionResult::Success(value) = result {
2784 assert_eq!(
2785 value.get("echoed").unwrap().as_str().unwrap(),
2786 "Hello, world!"
2787 );
2788 assert_eq!(value.get("length").unwrap().as_u64().unwrap(), 13);
2789 } else {
2790 panic!("Expected success");
2791 }
2792 }
2793
2794 #[tokio::test]
2795 async fn test_failing_tool_with_tool_error() {
2796 let tool = FailingTool::with_tool_error("Something went wrong");
2797
2798 let result = tool.execute(serde_json::json!({})).await;
2799
2800 if let ToolExecutionResult::ToolError(msg) = result {
2801 assert_eq!(msg, "Something went wrong");
2802 } else {
2803 panic!("Expected tool error");
2804 }
2805 }
2806
2807 #[tokio::test]
2808 async fn test_failing_tool_with_internal_error() {
2809 let tool = FailingTool::with_internal_error("Database connection failed");
2810
2811 let result = tool.execute(serde_json::json!({})).await;
2812
2813 if let ToolExecutionResult::InternalError(err) = result {
2814 assert_eq!(err.message, "Database connection failed");
2815 } else {
2816 panic!("Expected internal error");
2817 }
2818 }
2819
2820 #[tokio::test]
2821 async fn test_tool_result_conversion() {
2822 let result = ToolExecutionResult::success(serde_json::json!({"value": 42}));
2824 let tool_result = result.into_tool_result("call_1", "test_tool");
2825 assert!(tool_result.error.is_none());
2826 assert_eq!(tool_result.result.unwrap()["value"], 42);
2827
2828 let result = ToolExecutionResult::tool_error("Invalid input");
2830 let tool_result = result.into_tool_result("call_2", "test_tool");
2831 assert_eq!(tool_result.error.as_deref(), Some("Invalid input"));
2832 assert_eq!(
2833 tool_result.result.unwrap(),
2834 serde_json::json!({"error": "Invalid input"})
2835 );
2836
2837 let result = ToolExecutionResult::internal_error_msg("Secret database error");
2839 let tool_result = result.into_tool_result("call_3", "test_tool");
2840 assert_eq!(
2841 tool_result.error.as_deref(),
2842 Some("An internal error occurred while executing the tool")
2843 );
2844 assert_eq!(
2845 tool_result.result.unwrap(),
2846 serde_json::json!({"error": "An internal error occurred while executing the tool"})
2847 );
2848 }
2849
2850 #[tokio::test]
2851 async fn test_tool_registry() {
2852 let mut registry = ToolRegistry::new();
2853 registry.register(GetCurrentTimeTool);
2854 registry.register(EchoTool);
2855
2856 assert_eq!(registry.len(), 2);
2857 assert!(registry.has("get_current_time"));
2858 assert!(registry.has("echo"));
2859 assert!(!registry.has("nonexistent"));
2860
2861 let definitions = registry.tool_definitions();
2862 assert_eq!(definitions.len(), 2);
2863 }
2864
2865 #[tokio::test]
2866 async fn test_tool_registry_builder() {
2867 let registry = ToolRegistry::builder()
2868 .tool(GetCurrentTimeTool)
2869 .tool(EchoTool)
2870 .build();
2871
2872 assert_eq!(registry.len(), 2);
2873 }
2874
2875 #[test]
2876 fn test_tool_display_name_in_definition() {
2877 let tool = GetCurrentTimeTool;
2879 assert_eq!(tool.display_name(), Some("Get Current Time"));
2880
2881 let def = tool.to_definition();
2882 assert_eq!(def.display_name(), Some("Get Current Time"));
2883 }
2884
2885 #[test]
2886 fn test_success_with_raw_output_object_preserves_shape() {
2887 let res = ToolExecutionResult::success_with_raw_output(
2888 serde_json::json!({"stdout": "hello"}),
2889 "raw stdout bytes".to_string(),
2890 );
2891 let tr = res.into_tool_result("call_1", "demo");
2892 assert_eq!(tr.result.as_ref().unwrap()["stdout"], "hello");
2893 assert!(
2894 tr.result
2895 .as_ref()
2896 .unwrap()
2897 .as_object()
2898 .unwrap()
2899 .get("_raw_output")
2900 .is_none(),
2901 "sidecar key must not leak to the LLM-visible result"
2902 );
2903 assert_eq!(tr.raw_output.as_deref(), Some("raw stdout bytes"));
2904 }
2905
2906 #[test]
2907 fn test_success_with_raw_output_scalar_unwraps_to_string() {
2908 let res = ToolExecutionResult::success_with_raw_output(
2909 "compact summary".to_string(),
2910 "full output bytes".to_string(),
2911 );
2912 let tr = res.into_tool_result("call_1", "demo");
2913 assert_eq!(
2914 tr.result,
2915 Some(serde_json::Value::String("compact summary".into()))
2916 );
2917 assert_eq!(tr.raw_output.as_deref(), Some("full output bytes"));
2918 }
2919
2920 #[test]
2921 fn test_success_result_with_raw_output_scalar_key_is_not_unwrapped() {
2922 let res = ToolExecutionResult::success(
2923 serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}),
2924 );
2925 let tr = res.into_tool_result("call_1", "demo");
2926 assert_eq!(
2927 tr.result,
2928 Some(serde_json::json!({"_raw_output_scalar": "user_value", "kept": true}))
2929 );
2930 assert_eq!(tr.raw_output, None);
2931 }
2932
2933 #[test]
2934 fn test_success_result_with_only_raw_output_scalar_key_is_not_unwrapped() {
2935 let res = ToolExecutionResult::success(serde_json::json!({"_raw_output_scalar": "v"}));
2938 let tr = res.into_tool_result("call_1", "demo");
2939 assert_eq!(
2940 tr.result,
2941 Some(serde_json::json!({"_raw_output_scalar": "v"}))
2942 );
2943 assert_eq!(tr.raw_output, None);
2944 }
2945
2946 #[test]
2947 fn test_echo_tool_display_name() {
2948 let tool = EchoTool;
2949 assert_eq!(tool.display_name(), Some("Echo"));
2950
2951 let def = tool.to_definition();
2952 assert_eq!(def.display_name(), Some("Echo"));
2953 }
2954
2955 #[test]
2956 fn test_all_default_tools_have_display_names() {
2957 let registry = ToolRegistry::with_defaults();
2958 let definitions = registry.tool_definitions();
2959
2960 for def in &definitions {
2961 assert!(
2962 def.display_name().is_some(),
2963 "Tool '{}' should have a display_name",
2964 def.name()
2965 );
2966 }
2967 }
2968
2969 #[tokio::test]
2970 async fn test_tool_registry_as_executor() {
2971 let mut registry = ToolRegistry::new();
2972 registry.register(EchoTool);
2973
2974 let tool_call = ToolCall {
2975 id: "call_1".to_string(),
2976 name: "echo".to_string(),
2977 arguments: serde_json::json!({"message": "test"}),
2978 };
2979
2980 let tool_def = registry.get("echo").unwrap().to_definition();
2981 let result = registry.execute(&tool_call, &tool_def).await.unwrap();
2982
2983 assert!(result.error.is_none());
2984 assert_eq!(result.result.unwrap()["echoed"], "test");
2985 }
2986
2987 #[test]
2988 fn test_tool_to_definition() {
2989 let tool = GetCurrentTimeTool;
2990 let def = tool.to_definition();
2991
2992 let ToolDefinition::Builtin(builtin) = def else {
2993 panic!("expected Builtin variant");
2994 };
2995 assert_eq!(builtin.name, "get_current_time");
2996 assert_eq!(builtin.policy, ToolPolicy::Auto);
2997 }
2998
2999 #[test]
3000 fn test_with_defaults_has_expected_tools() {
3001 let registry = ToolRegistry::with_defaults();
3002
3003 assert!(
3005 registry.has("get_current_time"),
3006 "should have get_current_time"
3007 );
3008 assert!(registry.has("echo"), "should have echo");
3009 assert!(
3012 !registry.has("spawn_background"),
3013 "spawn_background must NOT be in defaults — it comes from the \
3014 background_execution capability"
3015 );
3016 assert!(
3017 registry.has("report_progress"),
3018 "should have report_progress"
3019 );
3020
3021 assert!(registry.has("add"), "should have add");
3023 assert!(registry.has("subtract"), "should have subtract");
3024 assert!(registry.has("multiply"), "should have multiply");
3025 assert!(registry.has("divide"), "should have divide");
3026
3027 assert!(registry.has("get_weather"), "should have get_weather");
3029 assert!(registry.has("get_forecast"), "should have get_forecast");
3030
3031 assert!(registry.has("write_todos"), "should have write_todos");
3033
3034 assert!(registry.has("read_file"), "should have read_file");
3036 assert!(registry.has("write_file"), "should have write_file");
3037 assert!(registry.has("edit_file"), "should have edit_file");
3038 assert!(registry.has("list_directory"), "should have list_directory");
3039 assert!(registry.has("grep_files"), "should have grep_files");
3040 assert!(registry.has("delete_file"), "should have delete_file");
3041 assert!(registry.has("stat_file"), "should have stat_file");
3042
3043 assert!(registry.has("web_fetch"), "should have web_fetch");
3045
3046 assert_eq!(registry.len(), 18, "should have 18 default tools");
3048 }
3049
3050 #[tokio::test]
3051 async fn test_with_defaults_tools_are_executable() {
3052 let registry = ToolRegistry::with_defaults();
3053
3054 let tool_call = ToolCall {
3056 id: "call_1".to_string(),
3057 name: "echo".to_string(),
3058 arguments: serde_json::json!({"message": "hello from defaults"}),
3059 };
3060
3061 let tool_def = registry.get("echo").unwrap().to_definition();
3062 let result = registry.execute(&tool_call, &tool_def).await.unwrap();
3063
3064 assert!(result.error.is_none());
3065 assert_eq!(result.result.unwrap()["echoed"], "hello from defaults");
3066 }
3067
3068 #[tokio::test]
3069 async fn test_with_defaults_math_tools() {
3070 let registry = ToolRegistry::with_defaults();
3071
3072 let tool_call = ToolCall {
3074 id: "call_add".to_string(),
3075 name: "add".to_string(),
3076 arguments: serde_json::json!({"a": 5, "b": 3}),
3077 };
3078
3079 let tool_def = registry.get("add").unwrap().to_definition();
3080 let result = registry.execute(&tool_call, &tool_def).await.unwrap();
3081
3082 assert!(result.error.is_none());
3083 assert_eq!(result.result.unwrap()["result"].as_f64().unwrap(), 8.0);
3085 }
3086
3087 #[test]
3091 fn test_with_defaults_excludes_capability_only_tools() {
3092 let registry = ToolRegistry::with_defaults();
3093
3094 assert!(
3096 !registry.has("bash"),
3097 "bash must not be in defaults — it comes from bashkit_shell capability"
3098 );
3099 assert!(
3101 !registry.has("kv_store"),
3102 "kv_store must not be in defaults — it comes from session_storage capability"
3103 );
3104 assert!(
3108 !registry.has("spawn_background"),
3109 "spawn_background must not be in defaults — it comes from the \
3110 background_execution capability (auto-activated by tool hints)"
3111 );
3112 }
3113
3114 #[tokio::test]
3115 async fn test_spawn_background_executes_and_signals_session() {
3116 let session_id = SessionId::new();
3117 let file_store = Arc::new(TestFileStore::default());
3118 let platform_store = Arc::new(TestPlatformStore::default());
3119 let storage_store = Arc::new(NoopStorageStore);
3120 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3121 let tool_registry = ToolRegistry::builder()
3122 .tool(SpawnBackgroundTool)
3123 .tool(TestBackgroundTool)
3124 .build();
3125
3126 let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3127 .with_tool_registry(Arc::new(tool_registry))
3128 .with_platform_store(platform_store.clone())
3129 .with_session_task_registry(task_registry.clone());
3130
3131 let tool = SpawnBackgroundTool;
3132 let result = tool
3133 .execute_with_context(
3134 json!({
3135 "tool": "test_background",
3136 "args": { "summary": "Background complete" }
3137 }),
3138 &context,
3139 )
3140 .await;
3141
3142 let ToolExecutionResult::Success(value) = result else {
3143 panic!("spawn_background should succeed");
3144 };
3145 let run_id = value["run_id"].as_str().unwrap().to_string();
3146 let task_id = value["task_id"].as_str().unwrap().to_string();
3147
3148 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3149 loop {
3150 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3151 && task.state == crate::session_task::SessionTaskState::Succeeded
3152 {
3153 break task;
3154 }
3155 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3156 }
3157 })
3158 .await
3159 .expect("background run should complete");
3160 let _ = run_id; let messages = platform_store.sent_messages.lock().unwrap().clone();
3163 assert_eq!(messages.len(), 1);
3164 assert!(messages[0].contains("Background run completed"));
3165
3166 let log_file = file_store
3167 .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3168 .await
3169 .unwrap()
3170 .expect("log file");
3171 assert!(
3172 log_file
3173 .content
3174 .as_deref()
3175 .unwrap_or_default()
3176 .contains("hello from background")
3177 );
3178 }
3179
3180 #[tokio::test]
3181 async fn test_spawn_background_persists_failure_artifacts() {
3182 let session_id = SessionId::new();
3183 let file_store = Arc::new(TestFileStore::default());
3184 let storage_store = Arc::new(NoopStorageStore);
3185 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3186 let tool_registry = ToolRegistry::builder()
3187 .tool(SpawnBackgroundTool)
3188 .tool(TestFailingBackgroundTool)
3189 .build();
3190
3191 let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3192 .with_tool_registry(Arc::new(tool_registry))
3193 .with_session_task_registry(task_registry.clone());
3194
3195 let result = SpawnBackgroundTool
3196 .execute_with_context(
3197 json!({
3198 "tool": "test_background_fail",
3199 "args": {}
3200 }),
3201 &context,
3202 )
3203 .await;
3204
3205 let ToolExecutionResult::Success(value) = result else {
3206 panic!("spawn_background should succeed");
3207 };
3208 let run_id = value["run_id"].as_str().unwrap().to_string();
3209 let task_id = value["task_id"].as_str().unwrap().to_string();
3210
3211 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3212 loop {
3213 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3214 && task.state == crate::session_task::SessionTaskState::Failed
3215 {
3216 break task;
3217 }
3218 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3219 }
3220 })
3221 .await
3222 .expect("background run should fail");
3223 let _ = run_id;
3224
3225 let log_file = file_store
3226 .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3227 .await
3228 .unwrap()
3229 .expect("log file");
3230 assert!(
3231 log_file
3232 .content
3233 .as_deref()
3234 .unwrap_or_default()
3235 .contains("background failed")
3236 );
3237
3238 let result_file = file_store
3239 .read_file(session_id, &format!("/.background/{run_id}/result.json"))
3240 .await
3241 .unwrap()
3242 .expect("result file");
3243 let result_json: Value =
3244 serde_json::from_str(result_file.content.as_deref().unwrap_or_default())
3245 .expect("valid json");
3246 assert_eq!(result_json["status"], "failed");
3247 assert_eq!(result_json["error"], "boom");
3248 }
3249
3250 #[tokio::test]
3251 async fn test_spawn_background_rejects_when_session_active_run_limit_reached() {
3252 let session_id = SessionId::new();
3253 let file_store = Arc::new(TestFileStore::default());
3254 let storage_store = Arc::new(NoopStorageStore);
3255 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3256 let release = StdArc::new(AtomicBool::new(false));
3257 let tool_registry = ToolRegistry::builder()
3258 .tool(SpawnBackgroundTool)
3259 .tool(BlockingBackgroundTool {
3260 release: release.clone(),
3261 })
3262 .build();
3263
3264 let context = ToolContext::with_stores(session_id, file_store, storage_store)
3265 .with_tool_registry(Arc::new(tool_registry))
3266 .with_session_task_registry(task_registry.clone());
3267
3268 let mut task_ids = Vec::new();
3269 for _ in 0..MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3270 let result = SpawnBackgroundTool
3271 .execute_with_context(
3272 json!({
3273 "tool": "test_background_blocking",
3274 "args": {}
3275 }),
3276 &context,
3277 )
3278 .await;
3279
3280 let ToolExecutionResult::Success(value) = result else {
3281 panic!("background run below the session limit should start");
3282 };
3283 task_ids.push(value["task_id"].as_str().unwrap().to_string());
3284 }
3285
3286 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3288 loop {
3289 let running = task_registry
3290 .list(
3291 session_id,
3292 Some(&crate::session_task::SessionTaskFilter {
3293 kind: Some(crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string()),
3294 state: Some(crate::session_task::SessionTaskState::Running),
3295 }),
3296 )
3297 .await
3298 .unwrap();
3299 if running.len() == MAX_ACTIVE_BACKGROUND_RUNS_PER_SESSION {
3300 break;
3301 }
3302 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3303 }
3304 })
3305 .await
3306 .expect("background runs should become running");
3307
3308 let result = SpawnBackgroundTool
3309 .execute_with_context(
3310 json!({
3311 "tool": "test_background_blocking",
3312 "args": {}
3313 }),
3314 &context,
3315 )
3316 .await;
3317
3318 let ToolExecutionResult::ToolError(message) = result else {
3319 release.store(true, Ordering::SeqCst);
3320 panic!("spawn_background should reject once the session limit is reached");
3321 };
3322 assert!(message.contains("active background runs per session"));
3323
3324 release.store(true, Ordering::SeqCst);
3325 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3326 for task_id in task_ids {
3327 loop {
3328 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3329 && task.state == crate::session_task::SessionTaskState::Succeeded
3330 {
3331 break;
3332 }
3333 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3334 }
3335 }
3336 })
3337 .await
3338 .expect("blocking background runs should complete after release");
3339
3340 tokio::time::timeout(std::time::Duration::from_secs(1), async {
3345 loop {
3346 if !has_session_background_permits(session_id) {
3347 break;
3348 }
3349 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3350 }
3351 })
3352 .await
3353 .expect("completed background runs should prune their per-session permit cache entry");
3354 }
3355
3356 #[tokio::test]
3357 async fn test_spawn_background_requires_task_registry() {
3358 let session_id = SessionId::new();
3359 let file_store = Arc::new(TestFileStore::default());
3360 let storage_store = Arc::new(NoopStorageStore);
3361 let tool_registry = ToolRegistry::builder()
3362 .tool(SpawnBackgroundTool)
3363 .tool(TestBackgroundTool)
3364 .build();
3365
3366 let context = ToolContext::with_stores(session_id, file_store, storage_store)
3368 .with_tool_registry(Arc::new(tool_registry));
3369
3370 let result = SpawnBackgroundTool
3371 .execute_with_context(
3372 json!({
3373 "tool": "test_background",
3374 "args": {}
3375 }),
3376 &context,
3377 )
3378 .await;
3379
3380 let ToolExecutionResult::ToolError(message) = result else {
3381 panic!("spawn_background should reject missing task registry");
3382 };
3383 assert!(message.contains("Session task registry not available"));
3384 }
3385
3386 #[tokio::test]
3387 async fn test_spawn_background_requires_file_store() {
3388 let session_id = SessionId::new();
3389 let storage_store = Arc::new(NoopStorageStore);
3390 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3391 let tool_registry = ToolRegistry::builder()
3392 .tool(SpawnBackgroundTool)
3393 .tool(TestBackgroundTool)
3394 .build();
3395
3396 let context = ToolContext::with_storage_store(session_id, storage_store)
3398 .with_tool_registry(Arc::new(tool_registry))
3399 .with_session_task_registry(task_registry);
3400
3401 let result = SpawnBackgroundTool
3402 .execute_with_context(
3403 json!({
3404 "tool": "test_background",
3405 "args": {}
3406 }),
3407 &context,
3408 )
3409 .await;
3410
3411 let ToolExecutionResult::ToolError(message) = result else {
3412 panic!("spawn_background should reject missing file store");
3413 };
3414 assert!(message.contains("Session file store not available"));
3415 }
3416
3417 #[tokio::test]
3418 async fn test_spawn_background_caps_output_log_size() {
3419 let session_id = SessionId::new();
3420 let file_store = Arc::new(TestFileStore::default());
3421 let storage_store = Arc::new(NoopStorageStore);
3422 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3423 let tool_registry = ToolRegistry::builder()
3424 .tool(SpawnBackgroundTool)
3425 .tool(TestLargeOutputBackgroundTool)
3426 .build();
3427
3428 let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3429 .with_tool_registry(Arc::new(tool_registry))
3430 .with_session_task_registry(task_registry.clone());
3431
3432 let result = SpawnBackgroundTool
3433 .execute_with_context(
3434 json!({
3435 "tool": "test_background_large_output",
3436 "args": {}
3437 }),
3438 &context,
3439 )
3440 .await;
3441
3442 let ToolExecutionResult::Success(value) = result else {
3443 panic!("spawn_background should succeed");
3444 };
3445 let run_id = value["run_id"].as_str().unwrap().to_string();
3446 let task_id = value["task_id"].as_str().unwrap().to_string();
3447
3448 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3449 loop {
3450 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3451 && task.state == crate::session_task::SessionTaskState::Succeeded
3452 {
3453 break;
3454 }
3455 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3456 }
3457 })
3458 .await
3459 .expect("background run should complete");
3460 let _ = run_id;
3461
3462 let log_content = file_store
3463 .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3464 .await
3465 .unwrap()
3466 .expect("log file")
3467 .content
3468 .unwrap_or_default();
3469
3470 assert!(log_content.contains("[system] background output truncated"));
3471 assert!(log_content.chars().count() <= MAX_BACKGROUND_OUTPUT_LOG_CHARS + 128);
3472 }
3473
3474 #[tokio::test]
3475 async fn test_spawn_background_can_create_scheduled_monitor() {
3476 let session_id = SessionId::new();
3477 let schedule_store = Arc::new(TestScheduleStore::default());
3478 let storage_store = Arc::new(NoopStorageStore);
3479 let tool_registry = ToolRegistry::builder()
3480 .tool(SpawnBackgroundTool)
3481 .tool(TestBackgroundTool)
3482 .build();
3483
3484 let context = ToolContext::with_storage_store(session_id, storage_store)
3485 .with_tool_registry(Arc::new(tool_registry))
3486 .with_schedule_store(schedule_store.clone());
3487
3488 let result = SpawnBackgroundTool
3489 .execute_with_context(
3490 json!({
3491 "tool": "test_background",
3492 "title": "Watch PR 1319",
3493 "args": { "summary": "Background complete" },
3494 "schedule": {
3495 "cron_expression": "*/10 * * * *",
3496 "timezone": "America/Chicago"
3497 }
3498 }),
3499 &context,
3500 )
3501 .await;
3502
3503 let ToolExecutionResult::Success(value) = result else {
3504 panic!("spawn_background should create a schedule: {result:?}");
3505 };
3506
3507 assert_eq!(value["status"], "scheduled");
3508 assert_eq!(value["title"], "Watch PR 1319");
3509 assert_eq!(value["cron_expression"], "*/10 * * * *");
3510 assert_eq!(value["timezone"], "America/Chicago");
3511
3512 let schedules = schedule_store.list_schedules(session_id).await.unwrap();
3513 assert_eq!(schedules.len(), 1);
3514 assert_eq!(
3515 schedules[0].cron_expression.as_deref(),
3516 Some("*/10 * * * *")
3517 );
3518 assert!(schedules[0].description.contains("Monitor: Watch PR 1319"));
3519 assert!(
3520 schedules[0]
3521 .description
3522 .contains("\"summary\": \"Background complete\"")
3523 );
3524 }
3525
3526 #[tokio::test]
3527 async fn test_spawn_background_rejects_invalid_scheduled_at() {
3528 let session_id = SessionId::new();
3529 let storage_store = Arc::new(NoopStorageStore);
3530 let tool_registry = ToolRegistry::builder()
3531 .tool(SpawnBackgroundTool)
3532 .tool(TestBackgroundTool)
3533 .build();
3534 let context = ToolContext::with_storage_store(session_id, storage_store)
3535 .with_tool_registry(Arc::new(tool_registry));
3536
3537 let result = SpawnBackgroundTool
3538 .execute_with_context(
3539 json!({
3540 "tool": "test_background",
3541 "args": {},
3542 "schedule": {
3543 "scheduled_at": "tomorrow at noon"
3544 }
3545 }),
3546 &context,
3547 )
3548 .await;
3549
3550 let ToolExecutionResult::ToolError(message) = result else {
3551 panic!("spawn_background should reject invalid scheduled_at");
3552 };
3553 assert!(message.contains("scheduled_at must be RFC3339"));
3554 }
3555
3556 #[tokio::test]
3557 async fn test_spawn_background_rejects_ambiguous_schedule_shape() {
3558 let session_id = SessionId::new();
3559 let storage_store = Arc::new(NoopStorageStore);
3560 let tool_registry = ToolRegistry::builder()
3561 .tool(SpawnBackgroundTool)
3562 .tool(TestBackgroundTool)
3563 .build();
3564 let context = ToolContext::with_storage_store(session_id, storage_store)
3565 .with_tool_registry(Arc::new(tool_registry));
3566
3567 let result = SpawnBackgroundTool
3568 .execute_with_context(
3569 json!({
3570 "tool": "test_background",
3571 "args": {},
3572 "schedule": {
3573 "cron_expression": "*/10 * * * *",
3574 "scheduled_at": "2026-04-16T15:30:00Z"
3575 }
3576 }),
3577 &context,
3578 )
3579 .await;
3580
3581 let ToolExecutionResult::ToolError(message) = result else {
3582 panic!("spawn_background should reject ambiguous schedule shape");
3583 };
3584 assert!(message.contains("must not include both cron_expression and scheduled_at"));
3585 }
3586
3587 #[test]
3592 fn test_is_canceled_outcome_detects_sentinel() {
3593 let sentinel: std::result::Result<BackgroundOutcome, ToolExecutionResult> = Err(
3595 ToolExecutionResult::ToolError(BACKGROUND_CANCEL_SENTINEL.to_string()),
3596 );
3597 assert!(is_canceled_outcome(&sentinel));
3598 }
3599
3600 #[test]
3601 fn test_is_canceled_outcome_does_not_match_other_errors() {
3602 let other_err: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
3603 Err(ToolExecutionResult::ToolError("boom".to_string()));
3604 assert!(!is_canceled_outcome(&other_err));
3605
3606 let success: std::result::Result<BackgroundOutcome, ToolExecutionResult> =
3607 Ok(BackgroundOutcome {
3608 summary: "ok".to_string(),
3609 result: json!({"ok": true}),
3610 raw_output: None,
3611 });
3612 assert!(!is_canceled_outcome(&success));
3613 }
3614
3615 #[derive(Default)]
3618 struct SleepingBackgroundTool;
3619
3620 #[async_trait]
3621 impl BackgroundExecutableTool for SleepingBackgroundTool {
3622 async fn execute_background(
3623 &self,
3624 _arguments: Value,
3625 _context: ToolContext,
3626 sink: Arc<dyn BackgroundEventSink>,
3627 ) -> std::result::Result<BackgroundOutcome, ToolExecutionResult> {
3628 sink.status("Sleeping forever")
3629 .await
3630 .map_err(ToolExecutionResult::internal_error)?;
3631 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
3633 Ok(BackgroundOutcome {
3634 summary: "should not reach here".to_string(),
3635 result: json!({}),
3636 raw_output: None,
3637 })
3638 }
3639 }
3640
3641 #[async_trait]
3642 impl Tool for SleepingBackgroundTool {
3643 fn name(&self) -> &str {
3644 "test_background_sleeping"
3645 }
3646
3647 fn display_name(&self) -> Option<&str> {
3648 Some("Test Background Sleeping")
3649 }
3650
3651 fn description(&self) -> &str {
3652 "background test tool that sleeps indefinitely"
3653 }
3654
3655 fn parameters_schema(&self) -> Value {
3656 json!({
3657 "type": "object",
3658 "properties": {}
3659 })
3660 }
3661
3662 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
3663 ToolExecutionResult::tool_error("foreground unsupported")
3664 }
3665
3666 fn hints(&self) -> ToolHints {
3667 ToolHints::default().with_supports_background(true)
3668 }
3669
3670 fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
3671 Some(self)
3672 }
3673 }
3674
3675 #[derive(Default)]
3679 struct InMemoryTaskRegistry {
3680 tasks: Mutex<HashMap<String, crate::session_task::SessionTask>>,
3681 }
3682
3683 #[async_trait]
3684 impl crate::session_task::SessionTaskRegistry for InMemoryTaskRegistry {
3685 async fn create(
3686 &self,
3687 input: crate::session_task::CreateSessionTask,
3688 ) -> crate::Result<crate::session_task::SessionTask> {
3689 let mut tasks = self.tasks.lock().unwrap();
3690 if let Some(id) = &input.id
3691 && let Some(existing) = tasks.get(id)
3692 {
3693 return Ok(existing.clone());
3694 }
3695 let task = crate::session_task::new_session_task(input, chrono::Utc::now());
3696 tasks.insert(task.id.clone(), task.clone());
3697 Ok(task)
3698 }
3699
3700 async fn update(
3701 &self,
3702 _session_id: SessionId,
3703 task_id: &str,
3704 update: crate::session_task::SessionTaskUpdate,
3705 ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3706 let mut tasks = self.tasks.lock().unwrap();
3707 let Some(task) = tasks.get_mut(task_id) else {
3708 return Ok(None);
3709 };
3710 crate::session_task::apply_task_update(task, update, chrono::Utc::now());
3711 Ok(Some(task.clone()))
3712 }
3713
3714 async fn get(
3715 &self,
3716 _session_id: SessionId,
3717 task_id: &str,
3718 ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3719 Ok(self.tasks.lock().unwrap().get(task_id).cloned())
3720 }
3721
3722 async fn list(
3723 &self,
3724 _session_id: SessionId,
3725 filter: Option<&crate::session_task::SessionTaskFilter>,
3726 ) -> crate::Result<Vec<crate::session_task::SessionTask>> {
3727 let tasks = self.tasks.lock().unwrap();
3728 Ok(tasks
3729 .values()
3730 .filter(|task| {
3731 filter.is_none_or(|f| {
3732 f.kind.as_deref().is_none_or(|kind| task.kind == kind)
3733 && f.state.is_none_or(|state| task.state == state)
3734 })
3735 })
3736 .cloned()
3737 .collect())
3738 }
3739
3740 async fn request_cancel(
3741 &self,
3742 _session_id: SessionId,
3743 task_id: &str,
3744 ) -> crate::Result<Option<crate::session_task::SessionTask>> {
3745 let mut tasks = self.tasks.lock().unwrap();
3746 let Some(task) = tasks.get_mut(task_id) else {
3747 return Ok(None);
3748 };
3749 task.cancel_requested_at
3750 .get_or_insert_with(chrono::Utc::now);
3751 task.updated_at = chrono::Utc::now();
3752 Ok(Some(task.clone()))
3753 }
3754
3755 async fn record_message(
3756 &self,
3757 _session_id: SessionId,
3758 task_id: &str,
3759 message: crate::session_task::NewTaskMessage,
3760 ) -> crate::Result<crate::session_task::TaskMessage> {
3761 let tasks = self.tasks.lock().unwrap();
3762 let _task = tasks
3763 .get(task_id)
3764 .ok_or_else(|| crate::AgentLoopError::tool(format!("no task {task_id}")))?;
3765 Ok(crate::session_task::TaskMessage {
3766 id: crate::session_task::generate_task_message_id(),
3767 task_id: task_id.to_string(),
3768 direction: message.direction,
3769 content: message.content,
3770 in_reply_to: message.in_reply_to,
3771 created_at: chrono::Utc::now(),
3772 })
3773 }
3774
3775 async fn list_messages(
3776 &self,
3777 _session_id: SessionId,
3778 _task_id: &str,
3779 _limit: Option<u32>,
3780 _after_id: Option<&str>,
3781 ) -> crate::Result<Vec<crate::session_task::TaskMessage>> {
3782 Ok(Vec::new())
3783 }
3784 }
3785
3786 #[tokio::test]
3789 async fn test_cancel_background_run_via_task_registry() {
3790 let session_id = SessionId::new();
3791 let file_store = Arc::new(TestFileStore::default());
3792 let storage_store = Arc::new(NoopStorageStore);
3793 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3794
3795 let tool_registry = ToolRegistry::builder()
3796 .tool(SpawnBackgroundTool)
3797 .tool(SleepingBackgroundTool)
3798 .build();
3799
3800 let context = ToolContext::with_stores(session_id, file_store.clone(), storage_store)
3801 .with_tool_registry(Arc::new(tool_registry))
3802 .with_session_task_registry(task_registry.clone());
3803
3804 let result = SpawnBackgroundTool
3805 .execute_with_context(
3806 json!({
3807 "tool": "test_background_sleeping",
3808 "args": {},
3809 "signal_on_completion": false
3810 }),
3811 &context,
3812 )
3813 .await;
3814
3815 let ToolExecutionResult::Success(value) = result else {
3816 panic!("spawn_background should succeed");
3817 };
3818 let run_id = value["run_id"].as_str().unwrap().to_string();
3819 let task_id = value["task_id"].as_str().unwrap().to_string();
3820
3821 tokio::time::timeout(std::time::Duration::from_secs(5), async {
3823 loop {
3824 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3826 && task.heartbeat_at.is_some()
3827 {
3828 break;
3829 }
3830 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3831 }
3832 })
3833 .await
3834 .expect("background run should start and send at least one heartbeat");
3835
3836 task_registry
3838 .request_cancel(session_id, &task_id)
3839 .await
3840 .expect("request_cancel should succeed");
3841
3842 tokio::time::timeout(std::time::Duration::from_secs(10), async {
3844 loop {
3845 if let Ok(Some(task)) = task_registry.get(session_id, &task_id).await
3846 && task.state == crate::session_task::SessionTaskState::Canceled
3847 {
3848 break task;
3849 }
3850 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3851 }
3852 })
3853 .await
3854 .expect("background task should reach Canceled state");
3855
3856 let result_file = file_store
3858 .read_file(session_id, &format!("/.background/{run_id}/result.json"))
3859 .await
3860 .unwrap()
3861 .expect("result.json should exist");
3862 let result_json: Value =
3863 serde_json::from_str(result_file.content.as_deref().unwrap_or_default())
3864 .expect("valid json");
3865 assert_eq!(result_json["status"], "canceled");
3866
3867 let log_file = file_store
3868 .read_file(session_id, &format!("/.background/{run_id}/output.log"))
3869 .await
3870 .unwrap()
3871 .expect("output.log should exist");
3872 assert!(
3873 log_file
3874 .content
3875 .as_deref()
3876 .unwrap_or_default()
3877 .contains("Canceled by request.")
3878 );
3879 }
3880
3881 fn make_reattach_task(spec: serde_json::Value) -> crate::session_task::SessionTask {
3886 use crate::session_task::{SessionTaskState, TaskLinks, TaskWakePolicy};
3887 crate::session_task::SessionTask {
3888 id: "t-reattach".to_string(),
3889 session_id: SessionId::new(),
3890 kind: crate::session_task::TASK_KIND_BACKGROUND_TOOL.to_string(),
3891 display_name: "Reattach test".to_string(),
3892 spec,
3893 state: SessionTaskState::Running,
3894 state_detail: None,
3895 progress: None,
3896 input_request: None,
3897 cancel_requested_at: None,
3898 summary: None,
3899 result_path: None,
3900 artifacts: vec![],
3901 error: None,
3902 attempt: 2,
3903 worker_id: None,
3904 heartbeat_at: None,
3905 links: TaskLinks::default(),
3906 wake_policy: TaskWakePolicy::Silent,
3907 created_at: chrono::Utc::now(),
3908 started_at: None,
3909 finished_at: None,
3910 updated_at: chrono::Utc::now(),
3911 }
3912 }
3913
3914 #[tokio::test]
3915 async fn reattach_fails_with_missing_file_store() {
3916 let session_id = SessionId::new();
3917 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3919 let context =
3920 crate::traits::ToolContext::new(session_id).with_session_task_registry(task_registry);
3921 let task = make_reattach_task(serde_json::json!({
3922 "tool": "get_current_time",
3923 "arguments": {},
3924 "reattachable": true,
3925 "signal_on_completion": true,
3926 }));
3927 let err = reattach_background_run(&task, &context)
3928 .await
3929 .expect_err("should fail without file store");
3930 assert!(
3931 err.to_string().contains("file store"),
3932 "error should mention file store, got: {err}"
3933 );
3934 }
3935
3936 #[tokio::test]
3937 async fn reattach_fails_with_missing_task_registry() {
3938 let session_id = SessionId::new();
3939 let file_store = Arc::new(TestFileStore::default());
3940 let storage_store = Arc::new(NoopStorageStore);
3941 let context =
3943 crate::traits::ToolContext::with_stores(session_id, file_store, storage_store);
3944 let task = make_reattach_task(serde_json::json!({
3945 "tool": "get_current_time",
3946 "arguments": {},
3947 "reattachable": true,
3948 "signal_on_completion": true,
3949 }));
3950 let err = reattach_background_run(&task, &context)
3951 .await
3952 .expect_err("should fail without task registry");
3953 assert!(
3954 err.to_string().contains("task registry"),
3955 "error should mention task registry, got: {err}"
3956 );
3957 }
3958
3959 #[tokio::test]
3960 async fn reattach_fails_with_unknown_tool_name() {
3961 let session_id = SessionId::new();
3962 let file_store = Arc::new(TestFileStore::default());
3963 let storage_store = Arc::new(NoopStorageStore);
3964 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3965 let context =
3966 crate::traits::ToolContext::with_stores(session_id, file_store, storage_store)
3967 .with_session_task_registry(task_registry);
3968 let task = make_reattach_task(serde_json::json!({
3970 "tool": "test_background",
3971 "arguments": {},
3972 "reattachable": true,
3973 "signal_on_completion": true,
3974 }));
3975 let err = reattach_background_run(&task, &context)
3976 .await
3977 .expect_err("should fail for unknown tool");
3978 assert!(
3979 err.to_string().contains("not found in built-in registry"),
3980 "error should mention built-in registry, got: {err}"
3981 );
3982 }
3983
3984 #[tokio::test]
3985 async fn reattach_fails_with_missing_tool_spec_field() {
3986 let session_id = SessionId::new();
3987 let file_store = Arc::new(TestFileStore::default());
3988 let storage_store = Arc::new(NoopStorageStore);
3989 let task_registry = Arc::new(InMemoryTaskRegistry::default());
3990 let context =
3991 crate::traits::ToolContext::with_stores(session_id, file_store, storage_store)
3992 .with_session_task_registry(task_registry);
3993 let task = make_reattach_task(serde_json::json!({ "reattachable": true }));
3995 let err = reattach_background_run(&task, &context)
3996 .await
3997 .expect_err("should fail with missing tool field");
3998 assert!(
3999 err.to_string().contains("missing 'tool' field"),
4000 "error should mention missing tool field, got: {err}"
4001 );
4002 }
4003}