mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add forum MCP tools — vote_on_post, kinds filter, updated descriptions (#112)
This commit is contained in:
@@ -161,6 +161,34 @@ Heartbeat is designed for idle periods. Under sustained event load it will rarel
|
||||
|
||||
Start with **N=2** for most deployments. Increase if queue depth grows under load. Each agent spawns its own MCP server subprocess, so resource usage scales approximately as N × (agent memory + MCP server memory). Maximum is 32.
|
||||
|
||||
## Forum Channels
|
||||
|
||||
By default, the ACP harness subscribes to stream message kinds (9, 46010, 40007). To receive forum events, opt in with `--kinds` and disable the mention filter (forum posts don't @mention agents):
|
||||
|
||||
**CLI flags:**
|
||||
```bash
|
||||
sprout-acp --kinds 9,46010,40007,45001,45002,45003 --no-mention-filter
|
||||
```
|
||||
|
||||
**Or with `--subscribe all`:**
|
||||
```bash
|
||||
sprout-acp --subscribe all --kinds 9,46010,40007,45001,45002,45003
|
||||
```
|
||||
|
||||
**Per-channel config:**
|
||||
```toml
|
||||
[channel.CHANNEL_UUID]
|
||||
kinds = [9, 46010, 40007, 45001, 45002, 45003]
|
||||
require_mention = false
|
||||
```
|
||||
|
||||
Forum event kinds:
|
||||
- **45001** — Forum post (thread root)
|
||||
- **45002** — Vote on a post or comment
|
||||
- **45003** — Comment reply on a forum post
|
||||
|
||||
> **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth.
|
||||
|
||||
@@ -551,8 +551,10 @@ impl Db {
|
||||
channel_id: Uuid,
|
||||
limit: u32,
|
||||
before: Option<DateTime<Utc>>,
|
||||
kind_filter: Option<&[u32]>,
|
||||
) -> Result<Vec<thread::TopLevelMessage>> {
|
||||
thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before).await
|
||||
thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before, kind_filter)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Decrement reply counts when a thread reply is deleted.
|
||||
|
||||
@@ -496,6 +496,7 @@ pub async fn get_channel_messages_top_level(
|
||||
channel_id: Uuid,
|
||||
limit: u32,
|
||||
before_cursor: Option<DateTime<Utc>>,
|
||||
kind_filter: Option<&[u32]>,
|
||||
) -> Result<Vec<TopLevelMessage>> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
|
||||
@@ -527,6 +528,17 @@ pub async fn get_channel_messages_top_level(
|
||||
sql.push_str(" AND e.created_at < ?");
|
||||
}
|
||||
|
||||
if let Some(kinds) = kind_filter {
|
||||
if !kinds.is_empty() {
|
||||
let list = kinds
|
||||
.iter()
|
||||
.map(|k| k.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
sql.push_str(&format!(" AND e.kind IN ({list})"));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY e.created_at DESC LIMIT ?");
|
||||
|
||||
let mut q = sqlx::query(&sql).bind(channel_id_bytes.as_slice());
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
//!
|
||||
//! ## Available Tools
|
||||
//!
|
||||
//! 41 tools total, organized into toolsets. Tools are organized into toolsets. Set
|
||||
//! 42 tools total, organized into toolsets. Tools are organized into toolsets. Set
|
||||
//! `SPROUT_TOOLSETS` to control which are active (default: 25 core tools).
|
||||
//!
|
||||
//! ### Messaging (default toolset)
|
||||
|
||||
@@ -120,12 +120,16 @@ pub struct GetMessagesParams {
|
||||
/// Maximum number of messages to return (default 50, max 200).
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
/// If true, fetch messages with thread metadata via REST instead of WebSocket.
|
||||
/// Legacy parameter (thread summaries are now always included). Kept for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub with_threads: Option<bool>,
|
||||
/// Unix timestamp cursor for pagination. Returns messages before this time.
|
||||
#[serde(default)]
|
||||
pub before: Option<i64>,
|
||||
/// Comma-separated event kind numbers to filter by (e.g. "45001" for forum posts,
|
||||
/// "45002" for votes). When omitted, all kinds are returned.
|
||||
#[serde(default)]
|
||||
pub kinds: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for the `list_channels` tool.
|
||||
@@ -539,6 +543,27 @@ pub struct SendDiffMessageParams {
|
||||
pub parent_event_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Vote direction for forum posts.
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VoteDirection {
|
||||
/// Upvote.
|
||||
Up,
|
||||
/// Downvote.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Parameters for the `vote_on_post` tool.
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct VoteOnPostParams {
|
||||
/// UUID of the forum channel.
|
||||
pub channel_id: String,
|
||||
/// 64-character hex event ID of the post or comment being voted on.
|
||||
pub event_id: String,
|
||||
/// Vote direction.
|
||||
pub direction: VoteDirection,
|
||||
}
|
||||
|
||||
// ── Diff utility functions ────────────────────────────────────────────────────
|
||||
|
||||
// Truncation notice appended when a diff is cut. This constant is used to
|
||||
@@ -657,7 +682,10 @@ impl SproutMcpServer {
|
||||
/// Send a message to a Sprout channel.
|
||||
#[tool(
|
||||
name = "send_message",
|
||||
description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. Set `broadcast_to_channel` to also surface the reply in the main channel timeline."
|
||||
description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \
|
||||
Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \
|
||||
For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \
|
||||
Default kind is 9 (stream message)."
|
||||
)]
|
||||
pub async fn send_message(&self, Parameters(p): Parameters<SendMessageParams>) -> String {
|
||||
if let Err(e) = validate_uuid(&p.channel_id) {
|
||||
@@ -923,7 +951,10 @@ impl SproutMcpServer {
|
||||
/// Get recent messages from a Sprout channel.
|
||||
#[tool(
|
||||
name = "get_messages",
|
||||
description = "Get recent messages from a Sprout channel. Use `before` for pagination (Unix timestamp). Set `with_threads=true` to include thread metadata."
|
||||
description = "Fetch recent top-level messages from a Sprout channel. Use `before` for pagination \
|
||||
(Unix timestamp). Use `kinds` to filter by event type (e.g. \"45001\" for forum posts, \
|
||||
\"45002\" for votes). Thread summaries are included automatically. Threaded replies \
|
||||
are not returned — use `get_thread` to fetch the full reply tree for a specific message."
|
||||
)]
|
||||
pub async fn get_messages(&self, Parameters(p): Parameters<GetMessagesParams>) -> String {
|
||||
if let Err(e) = validate_uuid(&p.channel_id) {
|
||||
@@ -933,8 +964,8 @@ impl SproutMcpServer {
|
||||
const MAX_HISTORY_LIMIT: u32 = 200;
|
||||
let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT);
|
||||
|
||||
// Use the REST endpoint so callers get the canonical history payload,
|
||||
// including thread metadata when requested.
|
||||
// Use the REST endpoint so callers get the canonical history payload.
|
||||
// Note: with_threads is legacy — summaries are always included server-side.
|
||||
let with_threads = p.with_threads.unwrap_or(false);
|
||||
let mut query_parts: Vec<String> = Vec::new();
|
||||
if with_threads {
|
||||
@@ -944,6 +975,9 @@ impl SproutMcpServer {
|
||||
if let Some(before) = p.before {
|
||||
query_parts.push(format!("before={before}"));
|
||||
}
|
||||
if let Some(ref kinds) = p.kinds {
|
||||
query_parts.push(format!("kinds={}", percent_encode(kinds)));
|
||||
}
|
||||
let path = format!(
|
||||
"/api/channels/{}/messages?{}",
|
||||
p.channel_id,
|
||||
@@ -1484,7 +1518,9 @@ impl SproutMcpServer {
|
||||
/// Get a message thread (replies to a message).
|
||||
#[tool(
|
||||
name = "get_thread",
|
||||
description = "Get a message thread from a Sprout channel. Returns the root message and all nested replies."
|
||||
description = "Fetch a full thread tree rooted at an event. Returns the root message and all nested \
|
||||
replies. Works for both stream message threads and forum post threads (kind:45001 root \
|
||||
with kind:45003 comments)."
|
||||
)]
|
||||
pub async fn get_thread(&self, Parameters(p): Parameters<GetThreadParams>) -> String {
|
||||
if let Err(e) = validate_uuid(&p.channel_id) {
|
||||
@@ -1763,6 +1799,57 @@ impl SproutMcpServer {
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vote on a forum post or comment (kind:45002).
|
||||
#[tool(
|
||||
name = "vote_on_post",
|
||||
description = "Vote on a forum post or comment. Creates a kind:45002 event. \
|
||||
Each vote is a separate event — vote deduplication is not yet enforced."
|
||||
)]
|
||||
pub async fn vote_on_post(&self, Parameters(p): Parameters<VoteOnPostParams>) -> String {
|
||||
if let Err(e) = validate_uuid(&p.channel_id) {
|
||||
return format!("Error: {e}");
|
||||
}
|
||||
if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return format!(
|
||||
"Error: event_id must be a 64-character hex string (got {:?})",
|
||||
p.event_id
|
||||
);
|
||||
}
|
||||
let content = match p.direction {
|
||||
VoteDirection::Up => "+",
|
||||
VoteDirection::Down => "-",
|
||||
};
|
||||
|
||||
let sender_pubkey = self.client.keys().public_key().to_hex();
|
||||
let tags = match (
|
||||
Tag::parse(&["h", &p.channel_id]),
|
||||
Tag::parse(&["p", &sender_pubkey]),
|
||||
Tag::parse(&["e", &p.event_id]),
|
||||
) {
|
||||
(Ok(h), Ok(p), Ok(e)) => vec![h, p, e],
|
||||
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => {
|
||||
return format!("Error: failed to build tags: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
let kind = Kind::from(sprout_core::kind::KIND_FORUM_VOTE as u16);
|
||||
let event = match EventBuilder::new(kind, content, tags).sign_with_keys(self.client.keys())
|
||||
{
|
||||
Ok(event) => event,
|
||||
Err(e) => return format!("Error signing event: {e}"),
|
||||
};
|
||||
|
||||
match self.client.send_event(event).await {
|
||||
Ok(ok) => serde_json::json!({
|
||||
"event_id": ok.event_id,
|
||||
"accepted": ok.accepted,
|
||||
"message": ok.message,
|
||||
})
|
||||
.to_string(),
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
@@ -2006,6 +2093,50 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ── VoteDirection serde ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn vote_direction_serializes_lowercase() {
|
||||
assert_eq!(serde_json::to_string(&VoteDirection::Up).unwrap(), "\"up\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&VoteDirection::Down).unwrap(),
|
||||
"\"down\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_direction_deserializes_lowercase() {
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<VoteDirection>("\"up\"").unwrap(),
|
||||
VoteDirection::Up
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<VoteDirection>("\"down\"").unwrap(),
|
||||
VoteDirection::Down
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_direction_rejects_invalid() {
|
||||
assert!(serde_json::from_str::<VoteDirection>("\"sideways\"").is_err());
|
||||
assert!(serde_json::from_str::<VoteDirection>("\"UP\"").is_err());
|
||||
assert!(serde_json::from_str::<VoteDirection>("\"\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_on_post_params_round_trip() {
|
||||
let params = VoteOnPostParams {
|
||||
channel_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
|
||||
event_id: "a".repeat(64),
|
||||
direction: VoteDirection::Up,
|
||||
};
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
let parsed: VoteOnPostParams = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.channel_id, params.channel_id);
|
||||
assert_eq!(parsed.event_id, params.event_id);
|
||||
assert!(matches!(parsed.direction, VoteDirection::Up));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | `canvas` | 2 |
|
||||
//! | `workflow_admin`| 5 |
|
||||
//! | `identity` | 1 |
|
||||
//! | `forums` | 1 |
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
@@ -39,7 +40,7 @@ use std::sync::LazyLock;
|
||||
/// classification. `is_read = true` means the tool is safe to include under
|
||||
/// a `:ro` (read-only) mode restriction.
|
||||
///
|
||||
/// 41 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented.
|
||||
/// 42 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented.
|
||||
pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
|
||||
// ── default ─────────────────────────────────────────────────────────────
|
||||
("send_message", "default", false),
|
||||
@@ -88,6 +89,8 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
|
||||
("get_workflow_runs", "workflow_admin", true),
|
||||
// ── identity ──────────────────────────────────────────────────────────────
|
||||
("set_channel_add_policy", "identity", false),
|
||||
// ── forums ───────────────────────────────────────────────────────────────
|
||||
("vote_on_post", "forums", false),
|
||||
// Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe
|
||||
];
|
||||
|
||||
@@ -152,6 +155,7 @@ const KNOWN_TOOLSETS: &[&str] = &[
|
||||
"media",
|
||||
"realtime",
|
||||
"identity",
|
||||
"forums",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -366,8 +370,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_count_is_41() {
|
||||
assert_eq!(ALL_TOOLS.len(), 41);
|
||||
fn all_tools_count_is_42() {
|
||||
assert_eq!(ALL_TOOLS.len(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -391,13 +395,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_toolsets_returns_correct_count() {
|
||||
// ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity
|
||||
// ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums
|
||||
// (media and realtime have no implemented tools yet)
|
||||
let defs = all_toolsets();
|
||||
assert_eq!(defs.len(), 6);
|
||||
assert_eq!(defs.len(), 7);
|
||||
let names: Vec<_> = defs.iter().map(|d| d.name).collect();
|
||||
assert!(names.contains(&"default"));
|
||||
assert!(names.contains(&"canvas"));
|
||||
assert!(names.contains(&"forums"));
|
||||
}
|
||||
|
||||
// ── Cross-check: ALL_TOOLS integrity ────────────────────────────────────
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! - `state.db.insert_thread_metadata(...)` → thread::insert_thread_metadata
|
||||
//! - `state.db.get_thread_replies(root_id, depth_limit, limit, cursor)` → thread::get_thread_replies
|
||||
//! - `state.db.get_thread_summary(event_id)` → thread::get_thread_summary
|
||||
//! - `state.db.get_channel_messages_top_level(channel_id, limit, before)` → thread::get_channel_messages_top_level
|
||||
//! - `state.db.get_channel_messages_top_level(channel_id, limit, before, kind_filter)` → thread::get_channel_messages_top_level
|
||||
//! - `state.db.get_thread_metadata_by_event(event_id)` → thread::get_thread_metadata_by_event
|
||||
//! - `state.db.get_event_by_id(id_bytes)` → event::get_event_by_id (already exists)
|
||||
//! - `state.db.insert_event(event, channel_id)` → event::insert_event (already exists)
|
||||
@@ -949,16 +949,19 @@ pub struct ListMessagesParams {
|
||||
/// Pagination cursor — Unix timestamp (seconds). Returns messages created
|
||||
/// strictly before this time.
|
||||
pub before: Option<i64>,
|
||||
/// When `true`, include thread summaries for each message.
|
||||
/// Legacy parameter (thread summaries are now always included). Kept for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub with_threads: bool,
|
||||
/// Comma-separated event kind numbers to filter by (e.g. "45001" or "9,45001").
|
||||
#[serde(default)]
|
||||
pub kinds: Option<String>,
|
||||
}
|
||||
|
||||
/// List top-level messages in a channel (newest first).
|
||||
///
|
||||
/// Returns root messages and broadcast replies. Thread replies are excluded
|
||||
/// unless `with_threads=true`, in which case each message includes a
|
||||
/// `thread_summary` with reply counts and participant pubkeys.
|
||||
/// Returns root messages and broadcast replies. Thread summaries (reply counts,
|
||||
/// participant pubkeys) are always included. Thread replies themselves are excluded —
|
||||
/// use `get_thread` to fetch the full reply tree for a specific message.
|
||||
pub async fn list_messages(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -982,9 +985,22 @@ pub async fn list_messages(
|
||||
.before
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
||||
|
||||
let kind_filter: Option<Vec<u32>> = params
|
||||
.kinds
|
||||
.as_deref()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|k| k.trim().parse::<u32>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|_| {
|
||||
api_error(StatusCode::BAD_REQUEST, "Invalid 'kinds' parameter — expected comma-separated integers (e.g. '45001' or '9,45001')")
|
||||
})?;
|
||||
|
||||
let mut messages = state
|
||||
.db
|
||||
.get_channel_messages_top_level(channel_id, limit, before_cursor)
|
||||
.get_channel_messages_top_level(channel_id, limit, before_cursor, kind_filter.as_deref())
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
@@ -1517,4 +1533,64 @@ mod tests {
|
||||
];
|
||||
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
|
||||
}
|
||||
|
||||
// ── kinds filter parsing ────────────────────────────────────────────────
|
||||
|
||||
/// Helper: simulate the kinds-parsing logic from `list_messages`.
|
||||
fn parse_kinds(input: Option<&str>) -> Result<Option<Vec<u32>>, ()> {
|
||||
input
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|k| k.trim().parse::<u32>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_none_returns_none() {
|
||||
assert_eq!(parse_kinds(None), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_single_value() {
|
||||
assert_eq!(parse_kinds(Some("45001")), Ok(Some(vec![45001])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_multiple_values() {
|
||||
assert_eq!(
|
||||
parse_kinds(Some("9,45001,45002")),
|
||||
Ok(Some(vec![9, 45001, 45002]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_with_whitespace() {
|
||||
assert_eq!(
|
||||
parse_kinds(Some("45001 , 45002")),
|
||||
Ok(Some(vec![45001, 45002]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_empty_string_is_error() {
|
||||
assert!(parse_kinds(Some("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_non_numeric_is_error() {
|
||||
assert!(parse_kinds(Some("abc")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_mixed_valid_invalid_is_error() {
|
||||
assert!(parse_kinds(Some("45001,abc")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_negative_is_error() {
|
||||
assert!(parse_kinds(Some("-1")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user