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::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
16/// Valid feedback categories.
17const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];
18
19/// Valid feedback sentiments.
20const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];
21
22/// Feedback file name.
23const FEEDBACK_FILE: &str = "feedback.md";
24
25/// Get feedback tool definitions.
26pub 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
74/// Resolve the feedback file path next to the database file.
75fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
76    db_dir.join(FEEDBACK_FILE)
77}
78
79/// Handle the give_feedback tool call.
80///
81/// When `db` is provided and the caller supplies an `agent_id`, the worker's
82/// workflow and overlays are looked up and recorded in the feedback entry.
83pub 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    // Check size limit before writing (0 = unlimited)
129    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    // If file doesn't exist yet, write the header
144    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    // Build the entry
153    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    // Optional metadata lines
158    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    // Look up workflow/overlay metadata from the worker record
169    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
195/// Handle the list_feedback tool call.
196pub 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}