Skip to main content

telos_agent/tools/builtin/
send_user_message.rs

1//! `SendUserMessage` tool (the "BriefTool" equivalent).
2//!
3//! The primary output channel for the agent to send messages to the user.
4//! Supports markdown-formatted messages with optional file attachments
5//! (images, diffs, logs). Has `normal` and `proactive` status labels.
6
7use 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
14/// `SendUserMessage` — send a message (with optional attachments) to the user.
15pub 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        // Resolve attachments
81        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    // Simple ISO 8601 timestamp without pulling in the `chrono` crate.
151    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    // Approximate: not a full calendar conversion but good enough for a timestamp.
155    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."#;