1use crate::config::FeedbackConfig;
6use crate::db::Database;
7use crate::error::ToolError;
8use crate::tools::{get_string, make_tool};
9use anyhow::Result;
10use rmcp::model::Tool;
11use serde_json::{Value, json};
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::Path;
15
16const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];
18
19const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];
21
22const FEEDBACK_FILE: &str = "feedback.md";
24
25pub fn get_tools() -> Vec<Tool> {
27 vec![
28 make_tool(
29 "give_feedback",
30 "Submit feedback about tools, workflows, configuration, or UX. \
31 Appends to a human-readable markdown file. Never shared automatically. \
32 Rejects writes if the file exceeds the configured size limit (default: 1MB).",
33 json!({
34 "message": {
35 "type": "string",
36 "description": "The feedback message"
37 },
38 "category": {
39 "type": "string",
40 "enum": CATEGORIES,
41 "description": "Feedback category (default: general)",
42 "default": "general"
43 },
44 "sentiment": {
45 "type": "string",
46 "enum": SENTIMENTS,
47 "description": "Sentiment of the feedback (default: neutral)",
48 "default": "neutral"
49 },
50 "agent_id": {
51 "type": "string",
52 "description": "ID of the agent submitting feedback"
53 },
54 "tool_name": {
55 "type": "string",
56 "description": "Name of the tool this feedback is about"
57 },
58 "task_id": {
59 "type": "string",
60 "description": "ID of the task this feedback relates to"
61 }
62 }),
63 vec!["message"],
64 ),
65 make_tool(
66 "list_feedback",
67 "Read the feedback markdown file. Returns the raw contents.",
68 json!({}),
69 vec![],
70 ),
71 ]
72}
73
74fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
76 db_dir.join(FEEDBACK_FILE)
77}
78
79pub fn give_feedback(
84 db_dir: &Path,
85 config: &FeedbackConfig,
86 db: Option<&Database>,
87 args: Value,
88) -> Result<Value> {
89 let message =
90 get_string(&args, "message").ok_or_else(|| ToolError::missing_field("message"))?;
91
92 if message.trim().is_empty() {
93 return Err(ToolError::invalid_value("message", "message cannot be empty").into());
94 }
95
96 let category = get_string(&args, "category").unwrap_or_else(|| "general".to_string());
97 if !CATEGORIES.contains(&category.as_str()) {
98 return Err(ToolError::invalid_value(
99 "category",
100 &format!(
101 "Invalid category '{}'. Must be one of: {}",
102 category,
103 CATEGORIES.join(", ")
104 ),
105 )
106 .into());
107 }
108
109 let sentiment = get_string(&args, "sentiment").unwrap_or_else(|| "neutral".to_string());
110 if !SENTIMENTS.contains(&sentiment.as_str()) {
111 return Err(ToolError::invalid_value(
112 "sentiment",
113 &format!(
114 "Invalid sentiment '{}'. Must be one of: {}",
115 sentiment,
116 SENTIMENTS.join(", ")
117 ),
118 )
119 .into());
120 }
121
122 let agent_id = get_string(&args, "agent_id");
123 let tool_name = get_string(&args, "tool_name");
124 let task_id = get_string(&args, "task_id");
125
126 let path = feedback_path(db_dir);
127
128 if config.max_size_bytes > 0 {
130 let current_size = path.metadata().map(|m| m.len()).unwrap_or(0);
131 if current_size >= config.max_size_bytes {
132 return Err(ToolError::invalid_value(
133 "feedback",
134 &format!(
135 "Feedback file has reached the size limit ({} bytes). No more feedback can be recorded.",
136 config.max_size_bytes
137 ),
138 )
139 .into());
140 }
141 }
142
143 let needs_header = !path.exists();
145
146 let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
147
148 if needs_header {
149 writeln!(file, "# Agent Feedback\n")?;
150 }
151
152 let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
154 writeln!(file, "---\n")?;
155 writeln!(file, "### {} | {} | {}\n", timestamp, category, sentiment)?;
156
157 if let Some(ref agent) = agent_id {
159 writeln!(file, "- **Agent:** {}", agent)?;
160 }
161 if let Some(ref tool) = tool_name {
162 writeln!(file, "- **Tool:** {}", tool)?;
163 }
164 if let Some(ref task) = task_id {
165 writeln!(file, "- **Task:** {}", task)?;
166 }
167
168 let mut has_workflow_meta = false;
170 if let (Some(agent), Some(db)) = (&agent_id, db)
171 && let Ok(Some(worker)) = db.get_worker(agent)
172 {
173 if let Some(ref wf) = worker.workflow {
174 writeln!(file, "- **Workflow:** {}", wf)?;
175 has_workflow_meta = true;
176 }
177 if !worker.overlays.is_empty() {
178 writeln!(file, "- **Overlays:** {}", worker.overlays.join(", "))?;
179 has_workflow_meta = true;
180 }
181 }
182
183 if agent_id.is_some() || tool_name.is_some() || task_id.is_some() || has_workflow_meta {
184 writeln!(file)?;
185 }
186
187 writeln!(file, "{}\n", message)?;
188
189 Ok(json!({
190 "status": "recorded",
191 "file": path.display().to_string()
192 }))
193}
194
195pub fn list_feedback(db_dir: &Path) -> Result<Value> {
197 let path = feedback_path(db_dir);
198
199 if !path.exists() {
200 return Ok(json!({
201 "content": "",
202 "file": path.display().to_string(),
203 "message": "No feedback recorded yet."
204 }));
205 }
206
207 let content = fs::read_to_string(&path)?;
208
209 Ok(json!({
210 "content": content,
211 "file": path.display().to_string()
212 }))
213}