telos_agent/tools/builtin/
send_user_message.rs1use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::path::{Path, PathBuf};
10
11use crate::error::AgentError;
12use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
13
14pub struct SendUserMessageTool;
16
17impl SendUserMessageTool {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23impl Default for SendUserMessageTool {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29#[async_trait]
30impl Tool for SendUserMessageTool {
31 fn definition(&self) -> ToolDefinition {
32 ToolDefinition {
33 name: "SendUserMessage".into(),
34 description:
35 "Primary output channel. Sends a markdown message to the user with optional file attachments (images, diffs, logs)."
36 .into(),
37 input_schema: json!({
38 "type": "object",
39 "properties": {
40 "message": {
41 "type": "string",
42 "description": "The message for the user. Supports markdown formatting."
43 },
44 "attachments": {
45 "type": "array",
46 "items": { "type": "string" },
47 "description": "Optional file paths (absolute or relative to cwd) to attach. Use for photos, screenshots, diffs, logs, or any file the user should see."
48 },
49 "status": {
50 "type": "string",
51 "enum": ["normal", "proactive"],
52 "description": "'proactive' when surfacing something the user hasn't asked for (task completion while away, blocker, unsolicited status update). 'normal' when replying to the user."
53 }
54 },
55 "required": ["message", "status"]
56 }),
57 }
58 }
59
60 fn prompt_text(&self) -> Option<&'static str> {
61 Some(SEND_USER_MESSAGE_PROMPT)
62 }
63
64 fn is_concurrency_safe(&self, _: &Value) -> bool {
65 true
66 }
67
68 async fn invoke(
69 &self,
70 arguments: Value,
71 context: ToolContext,
72 ) -> Result<ToolOutput, AgentError> {
73 let message = arguments
74 .get("message")
75 .and_then(|v| v.as_str())
76 .ok_or_else(|| AgentError::Validation("missing `message` field".into()))?;
77
78 let status = arguments.get("status").and_then(|v| v.as_str()).unwrap_or("normal");
79
80 let attachment_paths: Vec<String> = arguments
82 .get("attachments")
83 .and_then(|v| v.as_array())
84 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
85 .unwrap_or_default();
86
87 let mut resolved: Vec<serde_json::Value> = Vec::new();
88 for raw_path in &attachment_paths {
89 let path = resolve_path(&context.cwd, raw_path);
90 match std::fs::metadata(&path) {
91 Ok(meta) if meta.is_file() => {
92 let is_image = is_image_path(&path);
93 resolved.push(json!({
94 "path": raw_path,
95 "size": meta.len(),
96 "is_image": is_image
97 }));
98 }
99 Ok(_) => {
100 resolved.push(json!({
101 "path": raw_path,
102 "error": "not a regular file"
103 }));
104 }
105 Err(e) => {
106 resolved.push(json!({
107 "path": raw_path,
108 "error": format!("cannot access: {e}")
109 }));
110 }
111 }
112 }
113
114 let attachment_count = resolved.len();
115
116 Ok(ToolOutput::json(json!({
117 "message": message,
118 "attachments": resolved,
119 "status": status,
120 "sent_at": chrono_now(),
121 "feedback": format!("Message delivered to user{}",
122 if attachment_count > 0 {
123 format!(" with {attachment_count} attachment(s)")
124 } else {
125 String::new()
126 }
127 )
128 })))
129 }
130}
131
132fn resolve_path(cwd: &Path, raw: &str) -> PathBuf {
133 let p = PathBuf::from(raw);
134 if p.is_absolute() { p } else { cwd.join(p) }
135}
136
137fn is_image_path(path: &Path) -> bool {
138 path.extension()
139 .and_then(|ext| ext.to_str())
140 .map(|ext| {
141 matches!(
142 ext.to_lowercase().as_str(),
143 "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp"
144 )
145 })
146 .unwrap_or(false)
147}
148
149fn chrono_now() -> String {
150 use std::time::SystemTime;
152 let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
153 let secs = now.as_secs();
154 format!("{secs}")
156}
157
158const SEND_USER_MESSAGE_PROMPT: &str = r#"SendUserMessage is your primary output channel.
159
160Use this for all replies to the user. Pattern: acknowledge what you understood → do the work → SendUserMessage with the result. For long work, send checkpoint messages.
161
162- `status`: "normal" when replying, "proactive" for unsolicited updates (task completed while user away, blocker hit)
163- `message`: markdown. Lead with the answer, be concise, reference file paths.
164- `attachments` (optional): paths for images, diffs, logs."#;