use crate::config::FeedbackConfig;
use crate::db::Database;
use crate::error::ToolError;
use crate::tools::{get_string, make_tool};
use anyhow::Result;
use rmcp::model::Tool;
use serde_json::{Value, json};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];
const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];
const FEEDBACK_FILE: &str = "feedback.md";
pub fn get_tools() -> Vec<Tool> {
vec![
make_tool(
"give_feedback",
"Submit feedback about tools, workflows, configuration, or UX. \
Appends to a human-readable markdown file. Never shared automatically. \
Rejects writes if the file exceeds the configured size limit (default: 1MB).",
json!({
"message": {
"type": "string",
"description": "The feedback message"
},
"category": {
"type": "string",
"enum": CATEGORIES,
"description": "Feedback category (default: general)",
"default": "general"
},
"sentiment": {
"type": "string",
"enum": SENTIMENTS,
"description": "Sentiment of the feedback (default: neutral)",
"default": "neutral"
},
"agent_id": {
"type": "string",
"description": "ID of the agent submitting feedback"
},
"tool_name": {
"type": "string",
"description": "Name of the tool this feedback is about"
},
"task_id": {
"type": "string",
"description": "ID of the task this feedback relates to"
}
}),
vec!["message"],
),
make_tool(
"list_feedback",
"Read the feedback markdown file. Returns the raw contents.",
json!({}),
vec![],
),
]
}
fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
db_dir.join(FEEDBACK_FILE)
}
pub fn give_feedback(
db_dir: &Path,
config: &FeedbackConfig,
db: Option<&Database>,
args: Value,
) -> Result<Value> {
let message =
get_string(&args, "message").ok_or_else(|| ToolError::missing_field("message"))?;
if message.trim().is_empty() {
return Err(ToolError::invalid_value("message", "message cannot be empty").into());
}
let category = get_string(&args, "category").unwrap_or_else(|| "general".to_string());
if !CATEGORIES.contains(&category.as_str()) {
return Err(ToolError::invalid_value(
"category",
&format!(
"Invalid category '{}'. Must be one of: {}",
category,
CATEGORIES.join(", ")
),
)
.into());
}
let sentiment = get_string(&args, "sentiment").unwrap_or_else(|| "neutral".to_string());
if !SENTIMENTS.contains(&sentiment.as_str()) {
return Err(ToolError::invalid_value(
"sentiment",
&format!(
"Invalid sentiment '{}'. Must be one of: {}",
sentiment,
SENTIMENTS.join(", ")
),
)
.into());
}
let agent_id = get_string(&args, "agent_id");
let tool_name = get_string(&args, "tool_name");
let task_id = get_string(&args, "task_id");
let path = feedback_path(db_dir);
if config.max_size_bytes > 0 {
let current_size = path.metadata().map(|m| m.len()).unwrap_or(0);
if current_size >= config.max_size_bytes {
return Err(ToolError::invalid_value(
"feedback",
&format!(
"Feedback file has reached the size limit ({} bytes). No more feedback can be recorded.",
config.max_size_bytes
),
)
.into());
}
}
let needs_header = !path.exists();
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
if needs_header {
writeln!(file, "# Agent Feedback\n")?;
}
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
writeln!(file, "---\n")?;
writeln!(file, "### {} | {} | {}\n", timestamp, category, sentiment)?;
if let Some(ref agent) = agent_id {
writeln!(file, "- **Agent:** {}", agent)?;
}
if let Some(ref tool) = tool_name {
writeln!(file, "- **Tool:** {}", tool)?;
}
if let Some(ref task) = task_id {
writeln!(file, "- **Task:** {}", task)?;
}
let mut has_workflow_meta = false;
if let (Some(agent), Some(db)) = (&agent_id, db)
&& let Ok(Some(worker)) = db.get_worker(agent)
{
if let Some(ref wf) = worker.workflow {
writeln!(file, "- **Workflow:** {}", wf)?;
has_workflow_meta = true;
}
if !worker.overlays.is_empty() {
writeln!(file, "- **Overlays:** {}", worker.overlays.join(", "))?;
has_workflow_meta = true;
}
}
if agent_id.is_some() || tool_name.is_some() || task_id.is_some() || has_workflow_meta {
writeln!(file)?;
}
writeln!(file, "{}\n", message)?;
Ok(json!({
"status": "recorded",
"file": path.display().to_string()
}))
}
pub fn list_feedback(db_dir: &Path) -> Result<Value> {
let path = feedback_path(db_dir);
if !path.exists() {
return Ok(json!({
"content": "",
"file": path.display().to_string(),
"message": "No feedback recorded yet."
}));
}
let content = fs::read_to_string(&path)?;
Ok(json!({
"content": content,
"file": path.display().to_string()
}))
}