Skip to main content

task_graph_mcp/tools/
feedback.rs

1//! Agent feedback tools.
2//!
3//! Feedback is stored as a simple, human-readable, append-only markdown file.
4
5use crate::error::ToolError;
6use crate::tools::{get_string, make_tool};
7use anyhow::Result;
8use rmcp::model::Tool;
9use serde_json::{Value, json};
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12use std::path::Path;
13
14/// Valid feedback categories.
15const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];
16
17/// Valid feedback sentiments.
18const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];
19
20/// Feedback file name.
21const FEEDBACK_FILE: &str = "feedback.md";
22
23/// Get feedback tool definitions.
24pub fn get_tools() -> Vec<Tool> {
25    vec![
26        make_tool(
27            "give_feedback",
28            "Submit feedback about tools, workflows, configuration, or UX. \
29             Appends to a human-readable markdown file. Never shared automatically.",
30            json!({
31                "message": {
32                    "type": "string",
33                    "description": "The feedback message"
34                },
35                "category": {
36                    "type": "string",
37                    "enum": CATEGORIES,
38                    "description": "Feedback category (default: general)",
39                    "default": "general"
40                },
41                "sentiment": {
42                    "type": "string",
43                    "enum": SENTIMENTS,
44                    "description": "Sentiment of the feedback (default: neutral)",
45                    "default": "neutral"
46                },
47                "agent_id": {
48                    "type": "string",
49                    "description": "ID of the agent submitting feedback"
50                },
51                "tool_name": {
52                    "type": "string",
53                    "description": "Name of the tool this feedback is about"
54                },
55                "task_id": {
56                    "type": "string",
57                    "description": "ID of the task this feedback relates to"
58                }
59            }),
60            vec!["message"],
61        ),
62        make_tool(
63            "list_feedback",
64            "Read the feedback markdown file. Returns the raw contents.",
65            json!({}),
66            vec![],
67        ),
68    ]
69}
70
71/// Resolve the feedback file path next to the database file.
72fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
73    db_dir.join(FEEDBACK_FILE)
74}
75
76/// Handle the give_feedback tool call.
77pub fn give_feedback(db_dir: &Path, args: Value) -> Result<Value> {
78    let message =
79        get_string(&args, "message").ok_or_else(|| ToolError::missing_field("message"))?;
80
81    if message.trim().is_empty() {
82        return Err(ToolError::invalid_value("message", "message cannot be empty").into());
83    }
84
85    let category = get_string(&args, "category").unwrap_or_else(|| "general".to_string());
86    if !CATEGORIES.contains(&category.as_str()) {
87        return Err(ToolError::invalid_value(
88            "category",
89            &format!(
90                "Invalid category '{}'. Must be one of: {}",
91                category,
92                CATEGORIES.join(", ")
93            ),
94        )
95        .into());
96    }
97
98    let sentiment = get_string(&args, "sentiment").unwrap_or_else(|| "neutral".to_string());
99    if !SENTIMENTS.contains(&sentiment.as_str()) {
100        return Err(ToolError::invalid_value(
101            "sentiment",
102            &format!(
103                "Invalid sentiment '{}'. Must be one of: {}",
104                sentiment,
105                SENTIMENTS.join(", ")
106            ),
107        )
108        .into());
109    }
110
111    let agent_id = get_string(&args, "agent_id");
112    let tool_name = get_string(&args, "tool_name");
113    let task_id = get_string(&args, "task_id");
114
115    let path = feedback_path(db_dir);
116
117    // If file doesn't exist yet, write the header
118    let needs_header = !path.exists();
119
120    let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
121
122    if needs_header {
123        writeln!(file, "# Agent Feedback\n")?;
124    }
125
126    // Build the entry
127    let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
128    writeln!(file, "---\n")?;
129    writeln!(file, "### {} | {} | {}\n", timestamp, category, sentiment)?;
130
131    // Optional metadata lines
132    if let Some(ref agent) = agent_id {
133        writeln!(file, "- **Agent:** {}", agent)?;
134    }
135    if let Some(ref tool) = tool_name {
136        writeln!(file, "- **Tool:** {}", tool)?;
137    }
138    if let Some(ref task) = task_id {
139        writeln!(file, "- **Task:** {}", task)?;
140    }
141    if agent_id.is_some() || tool_name.is_some() || task_id.is_some() {
142        writeln!(file)?;
143    }
144
145    writeln!(file, "{}\n", message)?;
146
147    Ok(json!({
148        "status": "recorded",
149        "file": path.display().to_string()
150    }))
151}
152
153/// Handle the list_feedback tool call.
154pub fn list_feedback(db_dir: &Path) -> Result<Value> {
155    let path = feedback_path(db_dir);
156
157    if !path.exists() {
158        return Ok(json!({
159            "content": "",
160            "file": path.display().to_string(),
161            "message": "No feedback recorded yet."
162        }));
163    }
164
165    let content = fs::read_to_string(&path)?;
166
167    Ok(json!({
168        "content": content,
169        "file": path.display().to_string()
170    }))
171}