feat: add action registry with BM25 search index for tool discovery
Register all 23 agent-callable operations (messaging, channels, swarm, attachments) with full parameter metadata and usage examples. Provide in-memory BM25 text search over action documentation for tool discovery, with simple stemming and compound-token matching so exact action names rank highest. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
67783566d7
commit
7f74357437
@@ -0,0 +1,458 @@
|
||||
package actions
|
||||
|
||||
// Registry holds all action definitions and supports lookup.
|
||||
type Registry struct {
|
||||
actions map[string]Action
|
||||
ordered []Action // maintains insertion order
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry pre-populated with all 23 agent-callable actions.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
actions: make(map[string]Action, 23),
|
||||
}
|
||||
for _, a := range allActions() {
|
||||
r.actions[a.Name] = a
|
||||
r.ordered = append(r.ordered, a)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns an action by name.
|
||||
func (r *Registry) Get(name string) (Action, bool) {
|
||||
a, ok := r.actions[name]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// List returns all registered actions.
|
||||
func (r *Registry) List() []Action {
|
||||
out := make([]Action, len(r.ordered))
|
||||
copy(out, r.ordered)
|
||||
return out
|
||||
}
|
||||
|
||||
// ListByCategory returns actions in the given category.
|
||||
func (r *Registry) ListByCategory(category string) []Action {
|
||||
var out []Action
|
||||
for _, a := range r.ordered {
|
||||
if a.Category == category {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allActions returns the canonical list of all 23 agent-callable actions.
|
||||
func allActions() []Action {
|
||||
return []Action{
|
||||
// ── Messaging (7 actions) ──────────────────────────────────────
|
||||
{
|
||||
Name: "my_status",
|
||||
Category: "messaging",
|
||||
Description: "Get your complete status overview — identity, pending messages, channel mentions, system notifications, and statistics. Call this first when connecting to SynapBus.",
|
||||
Params: []Param{},
|
||||
Returns: "JSON with agent identity, direct_messages, mentions, system_notifications, channels, and stats",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Check your full status on connect",
|
||||
Code: `call("my_status", {})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send_message",
|
||||
Category: "messaging",
|
||||
Description: "Send a direct message to another agent. Use discover_agents first to find available agents you can communicate with. For channel messages, use send_channel_message instead.",
|
||||
Params: []Param{
|
||||
{Name: "to", Type: "string", Description: "Name of the recipient agent (required for DMs, omit for channel messages)"},
|
||||
{Name: "body", Type: "string", Description: "Message body text", Required: true},
|
||||
{Name: "subject", Type: "string", Description: "Conversation subject (optional)"},
|
||||
{Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"},
|
||||
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
|
||||
{Name: "channel_id", Type: "number", Description: "Channel ID for channel messages (optional)"},
|
||||
{Name: "reply_to", Type: "number", Description: "ID of the message to reply to (optional, for threading)"},
|
||||
},
|
||||
Returns: "JSON with message_id, conversation_id, and status",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Send a direct message to another agent",
|
||||
Code: `call("send_message", {"to": "data-processor", "body": "Please analyze the Q4 sales data", "subject": "Q4 Analysis", "priority": 7})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_inbox",
|
||||
Category: "messaging",
|
||||
Description: "Check your message inbox for pending messages. Call this first when connecting to see if other agents have sent you messages. Returns unread/pending direct messages addressed to you.",
|
||||
Params: []Param{
|
||||
{Name: "limit", Type: "number", Description: "Maximum number of messages to return (default 50)", Default: "50"},
|
||||
{Name: "status_filter", Type: "string", Description: "Filter by message status: pending, processing, done, failed"},
|
||||
{Name: "include_read", Type: "boolean", Description: "Include previously read messages (default false)", Default: "false"},
|
||||
{Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"},
|
||||
{Name: "from_agent", Type: "string", Description: "Filter by sender agent name"},
|
||||
},
|
||||
Returns: "JSON with messages array and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Check for new messages",
|
||||
Code: `call("read_inbox", {})`,
|
||||
},
|
||||
{
|
||||
Description: "Read high-priority messages from a specific agent",
|
||||
Code: `call("read_inbox", {"min_priority": 8, "from_agent": "coordinator"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "claim_messages",
|
||||
Category: "messaging",
|
||||
Description: "Atomically claim pending messages for processing",
|
||||
Params: []Param{
|
||||
{Name: "limit", Type: "number", Description: "Maximum number of messages to claim (default 10)", Default: "10"},
|
||||
},
|
||||
Returns: "JSON with claimed messages array and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Claim up to 5 messages for processing",
|
||||
Code: `call("claim_messages", {"limit": 5})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mark_done",
|
||||
Category: "messaging",
|
||||
Description: "Mark a claimed message as done or failed",
|
||||
Params: []Param{
|
||||
{Name: "message_id", Type: "number", Description: "ID of the message to mark", Required: true},
|
||||
{Name: "status", Type: "string", Description: "New status: 'done' or 'failed' (default 'done')", Default: "done"},
|
||||
{Name: "reason", Type: "string", Description: "Failure reason (only for status='failed')"},
|
||||
},
|
||||
Returns: "JSON with message_id and status",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Mark a message as successfully processed",
|
||||
Code: `call("mark_done", {"message_id": 42})`,
|
||||
},
|
||||
{
|
||||
Description: "Mark a message as failed with reason",
|
||||
Code: `call("mark_done", {"message_id": 42, "status": "failed", "reason": "invalid data format"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_messages",
|
||||
Category: "messaging",
|
||||
Description: "Search for messages across your inbox and channels you are a member of. Supports full-text and semantic search (if configured). Use with an empty query to browse recent messages, or provide a natural-language query to find relevant conversations.",
|
||||
Params: []Param{
|
||||
{Name: "query", Type: "string", Description: "Search query string — supports natural language for semantic search"},
|
||||
{Name: "limit", Type: "number", Description: "Maximum results to return (default 10, max 100)", Default: "10"},
|
||||
{Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"},
|
||||
{Name: "from_agent", Type: "string", Description: "Filter by sender agent name"},
|
||||
{Name: "status", Type: "string", Description: "Filter by message status"},
|
||||
{Name: "search_mode", Type: "string", Description: "Search mode: 'auto' (default), 'semantic', or 'fulltext'", Default: "auto"},
|
||||
{Name: "semantic", Type: "boolean", Description: "Force semantic search (shorthand for search_mode='semantic')"},
|
||||
},
|
||||
Returns: "JSON with results array, count, and search_mode used",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Search for messages about deployment",
|
||||
Code: `call("search_messages", {"query": "deployment status update", "limit": 5})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "discover_agents",
|
||||
Category: "messaging",
|
||||
Description: "Discover other agents on the bus. Call this to find agents you can communicate with. Optionally filter by capability keywords, or omit the query to list all registered agents.",
|
||||
Params: []Param{
|
||||
{Name: "query", Type: "string", Description: "Capability keyword to search for"},
|
||||
},
|
||||
Returns: "JSON with agents array (name, display_name, type, capabilities, status) and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "List all available agents",
|
||||
Code: `call("discover_agents", {})`,
|
||||
},
|
||||
{
|
||||
Description: "Find agents with data analysis capabilities",
|
||||
Code: `call("discover_agents", {"query": "data analysis"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Channels (9 actions) ──────────────────────────────────────
|
||||
{
|
||||
Name: "create_channel",
|
||||
Category: "channels",
|
||||
Description: "Create a new channel for group communication",
|
||||
Params: []Param{
|
||||
{Name: "name", Type: "string", Description: "Unique channel name (alphanumeric, hyphens, underscores, max 64 chars)", Required: true},
|
||||
{Name: "description", Type: "string", Description: "Channel description"},
|
||||
{Name: "topic", Type: "string", Description: "Current channel topic"},
|
||||
{Name: "type", Type: "string", Description: "Channel type: 'standard', 'blackboard', or 'auction' (default 'standard')", Default: "standard"},
|
||||
{Name: "is_private", Type: "boolean", Description: "Whether the channel is private (invite-only). Default false", Default: "false"},
|
||||
},
|
||||
Returns: "JSON with channel_id, name, description, topic, type, is_private, created_by",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Create a public channel for project discussion",
|
||||
Code: `call("create_channel", {"name": "project-alpha", "description": "Discussion for Project Alpha", "topic": "Sprint planning"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "join_channel",
|
||||
Category: "channels",
|
||||
Description: "Join a channel to participate in group conversations. You will receive messages sent to the channel after joining. Use list_channels first to see available channels.",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel to join"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel to join (alternative to channel_id)"},
|
||||
},
|
||||
Returns: "JSON with channel_id and status 'joined'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Join a channel by name",
|
||||
Code: `call("join_channel", {"channel_name": "project-alpha"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "leave_channel",
|
||||
Category: "channels",
|
||||
Description: "Leave a channel you are a member of",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel to leave"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel to leave (alternative to channel_id)"},
|
||||
},
|
||||
Returns: "JSON with channel_id and status 'left'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Leave a channel by name",
|
||||
Code: `call("leave_channel", {"channel_name": "project-alpha"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_channels",
|
||||
Category: "channels",
|
||||
Description: "List all channels visible to you. Call this when connecting to see available channels and join conversations. Shows all public channels plus private channels you are a member of or have been invited to.",
|
||||
Params: []Param{},
|
||||
Returns: "JSON with channels array (id, name, description, topic, type, is_private, created_by, member_count) and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "List all available channels",
|
||||
Code: `call("list_channels", {})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invite_to_channel",
|
||||
Category: "channels",
|
||||
Description: "Invite an agent to a channel (only the channel owner can invite to private channels)",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
|
||||
{Name: "agent_name", Type: "string", Description: "Name of the agent to invite", Required: true},
|
||||
},
|
||||
Returns: "JSON with channel_id, agent_name, and status 'invited'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Invite an agent to a private channel",
|
||||
Code: `call("invite_to_channel", {"channel_name": "secret-ops", "agent_name": "data-processor"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "kick_from_channel",
|
||||
Category: "channels",
|
||||
Description: "Remove an agent from a channel (only the channel owner can kick)",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
|
||||
{Name: "agent_name", Type: "string", Description: "Name of the agent to kick", Required: true},
|
||||
},
|
||||
Returns: "JSON with channel_id, agent_name, and status 'kicked'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Remove an agent from a channel",
|
||||
Code: `call("kick_from_channel", {"channel_name": "project-alpha", "agent_name": "spambot"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_channel_messages",
|
||||
Category: "channels",
|
||||
Description: "Get recent messages from a channel you are a member of",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
|
||||
{Name: "limit", Type: "number", Description: "Max number of messages to return (default 50, max 200)", Default: "50"},
|
||||
},
|
||||
Returns: "JSON with channel_id, messages array, and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Get recent messages from a channel",
|
||||
Code: `call("get_channel_messages", {"channel_name": "project-alpha", "limit": 20})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "send_channel_message",
|
||||
Category: "channels",
|
||||
Description: "Send a message to all members of a channel. Use @agentname in the body to mention specific agents. You must be a member of the channel to send messages.",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
|
||||
{Name: "body", Type: "string", Description: "Message body text", Required: true},
|
||||
{Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"},
|
||||
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
|
||||
},
|
||||
Returns: "JSON with channel_id, message_id, and status 'sent'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Send a message to a channel with a mention",
|
||||
Code: `call("send_channel_message", {"channel_name": "project-alpha", "body": "Hey @coordinator, the build is ready for review"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_channel",
|
||||
Category: "channels",
|
||||
Description: "Update channel topic or description (only the channel owner can update)",
|
||||
Params: []Param{
|
||||
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
|
||||
{Name: "topic", Type: "string", Description: "New channel topic"},
|
||||
{Name: "description", Type: "string", Description: "New channel description"},
|
||||
},
|
||||
Returns: "JSON with channel_id, name, description, and topic",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Update a channel's topic",
|
||||
Code: `call("update_channel", {"channel_name": "project-alpha", "topic": "v2.0 release planning"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Swarm (5 actions) ─────────────────────────────────────────
|
||||
{
|
||||
Name: "post_task",
|
||||
Category: "swarm",
|
||||
Description: "Post a task to an auction channel for agents to bid on",
|
||||
Params: []Param{
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
|
||||
{Name: "title", Type: "string", Description: "Task title", Required: true},
|
||||
{Name: "description", Type: "string", Description: "Task description"},
|
||||
{Name: "requirements", Type: "string", Description: "JSON object of task requirements"},
|
||||
{Name: "deadline", Type: "string", Description: "Task deadline in ISO 8601 format (e.g. 2026-03-13T15:00:00Z)"},
|
||||
},
|
||||
Returns: "JSON with task_id, channel_id, title, status, posted_by, deadline, created_at",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Post a data analysis task to an auction channel",
|
||||
Code: `call("post_task", {"channel_name": "task-marketplace", "title": "Analyze Q4 revenue", "description": "Run trend analysis on Q4 revenue data", "deadline": "2026-03-20T17:00:00Z"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bid_task",
|
||||
Category: "swarm",
|
||||
Description: "Submit a bid on an open task in an auction channel",
|
||||
Params: []Param{
|
||||
{Name: "task_id", Type: "number", Description: "ID of the task to bid on", Required: true},
|
||||
{Name: "capabilities", Type: "string", Description: "JSON object describing your relevant capabilities"},
|
||||
{Name: "time_estimate", Type: "string", Description: "Estimated time to complete the task"},
|
||||
{Name: "message", Type: "string", Description: "Message to the task poster explaining your bid"},
|
||||
},
|
||||
Returns: "JSON with bid_id, task_id, agent_name, time_estimate, status",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Bid on a task with capabilities and time estimate",
|
||||
Code: `call("bid_task", {"task_id": 7, "capabilities": "{\"skills\": [\"data-analysis\", \"python\"]}", "time_estimate": "2 hours", "message": "I have experience with revenue trend analysis"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "accept_bid",
|
||||
Category: "swarm",
|
||||
Description: "Accept a bid on a task you posted, assigning the task to the bidding agent",
|
||||
Params: []Param{
|
||||
{Name: "task_id", Type: "number", Description: "ID of the task", Required: true},
|
||||
{Name: "bid_id", Type: "number", Description: "ID of the bid to accept", Required: true},
|
||||
},
|
||||
Returns: "JSON with task_id, bid_id, and status 'accepted'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Accept a bid on your task",
|
||||
Code: `call("accept_bid", {"task_id": 7, "bid_id": 3})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "complete_task",
|
||||
Category: "swarm",
|
||||
Description: "Mark a task as completed (only the assigned agent can do this)",
|
||||
Params: []Param{
|
||||
{Name: "task_id", Type: "number", Description: "ID of the task to complete", Required: true},
|
||||
},
|
||||
Returns: "JSON with task_id and status 'completed'",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Mark an assigned task as completed",
|
||||
Code: `call("complete_task", {"task_id": 7})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_tasks",
|
||||
Category: "swarm",
|
||||
Description: "List tasks in an auction channel, optionally filtered by status",
|
||||
Params: []Param{
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
|
||||
{Name: "status", Type: "string", Description: "Filter by task status: open, assigned, completed, cancelled"},
|
||||
},
|
||||
Returns: "JSON with tasks array (id, title, description, status, posted_by, assigned_to, deadline, created_at) and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "List open tasks in an auction channel",
|
||||
Code: `call("list_tasks", {"channel_name": "task-marketplace", "status": "open"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Attachments (2 actions) ───────────────────────────────────
|
||||
{
|
||||
Name: "upload_attachment",
|
||||
Category: "attachments",
|
||||
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Max file size: 50MB.",
|
||||
Params: []Param{
|
||||
{Name: "content", Type: "string", Description: "Base64-encoded file content", Required: true},
|
||||
{Name: "filename", Type: "string", Description: "Original filename (optional, used for MIME detection and display)"},
|
||||
{Name: "mime_type", Type: "string", Description: "MIME type override (optional, auto-detected from content if not provided)"},
|
||||
{Name: "message_id", Type: "number", Description: "Message ID to attach the file to (optional, can be linked later)"},
|
||||
},
|
||||
Returns: "JSON with hash, size, mime_type, original_filename",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Upload a text file attachment",
|
||||
Code: `call("upload_attachment", {"content": "SGVsbG8gV29ybGQ=", "filename": "hello.txt", "mime_type": "text/plain"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download_attachment",
|
||||
Category: "attachments",
|
||||
Description: "Download an attachment by its SHA-256 hash. Returns base64-encoded content along with filename and MIME type metadata.",
|
||||
Params: []Param{
|
||||
{Name: "hash", Type: "string", Description: "SHA-256 hash of the attachment", Required: true},
|
||||
},
|
||||
Returns: "JSON with hash, content (base64), original_filename, mime_type, size",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Download an attachment by hash",
|
||||
Code: `call("download_attachment", {"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryHas23Actions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
got := len(r.List())
|
||||
if got != 23 {
|
||||
t.Errorf("expected 23 actions, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCategories(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
tests := []struct {
|
||||
category string
|
||||
want int
|
||||
}{
|
||||
{"messaging", 7},
|
||||
{"channels", 9},
|
||||
{"swarm", 5},
|
||||
{"attachments", 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.category, func(t *testing.T) {
|
||||
got := len(r.ListByCategory(tt.category))
|
||||
if got != tt.want {
|
||||
t.Errorf("category %q: expected %d actions, got %d", tt.category, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetByName(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
allNames := []string{
|
||||
// messaging
|
||||
"my_status", "send_message", "read_inbox", "claim_messages", "mark_done", "search_messages", "discover_agents",
|
||||
// channels
|
||||
"create_channel", "join_channel", "leave_channel", "list_channels",
|
||||
"invite_to_channel", "kick_from_channel", "get_channel_messages",
|
||||
"send_channel_message", "update_channel",
|
||||
// swarm
|
||||
"post_task", "bid_task", "accept_bid", "complete_task", "list_tasks",
|
||||
// attachments
|
||||
"upload_attachment", "download_attachment",
|
||||
}
|
||||
|
||||
for _, name := range allNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
a, ok := r.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("action %q not found in registry", name)
|
||||
}
|
||||
if a.Name != name {
|
||||
t.Errorf("expected name %q, got %q", name, a.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetNotFound(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
_, ok := r.Get("nonexistent_action")
|
||||
if ok {
|
||||
t.Error("expected Get to return false for nonexistent action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryActionsHaveExamples(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, a := range r.List() {
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
if len(a.Examples) == 0 {
|
||||
t.Errorf("action %q has no examples", a.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryActionsHaveDescriptions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, a := range r.List() {
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
if a.Description == "" {
|
||||
t.Errorf("action %q has empty description", a.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryActionsHaveReturns(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, a := range r.List() {
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
if a.Returns == "" {
|
||||
t.Errorf("action %q has empty Returns field", a.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryListByUnknownCategory(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
got := r.ListByCategory("nonexistent")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0 actions for unknown category, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryListReturnsCopy(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
list1 := r.List()
|
||||
list2 := r.List()
|
||||
// Mutating the first list should not affect the second.
|
||||
if len(list1) > 0 {
|
||||
list1[0].Name = "mutated"
|
||||
if list2[0].Name == "mutated" {
|
||||
t.Error("List() should return a copy, not a reference to internal slice")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Index is an in-memory BM25 search index over action documentation.
|
||||
type Index struct {
|
||||
actions []Action
|
||||
docs []document // tokenized documents, one per action
|
||||
avgDL float64 // average document length
|
||||
df map[string]int // document frequency per term
|
||||
n int // total number of documents
|
||||
}
|
||||
|
||||
// document holds the pre-tokenized content for a single action.
|
||||
type document struct {
|
||||
terms []string // all tokens in the document
|
||||
tf map[string]int // term frequency
|
||||
}
|
||||
|
||||
// BM25 parameters
|
||||
const (
|
||||
bm25K1 = 1.2
|
||||
bm25B = 0.75
|
||||
)
|
||||
|
||||
// NewIndex builds a BM25 index from the given actions.
|
||||
func NewIndex(actions []Action) *Index {
|
||||
idx := &Index{
|
||||
actions: make([]Action, len(actions)),
|
||||
docs: make([]document, len(actions)),
|
||||
df: make(map[string]int),
|
||||
n: len(actions),
|
||||
}
|
||||
copy(idx.actions, actions)
|
||||
|
||||
totalLen := 0
|
||||
for i, a := range actions {
|
||||
tokens := tokenizeAction(a)
|
||||
tf := make(map[string]int, len(tokens))
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
idx.docs[i] = document{terms: tokens, tf: tf}
|
||||
totalLen += len(tokens)
|
||||
}
|
||||
|
||||
if idx.n > 0 {
|
||||
idx.avgDL = float64(totalLen) / float64(idx.n)
|
||||
}
|
||||
|
||||
// Compute document frequency for each term.
|
||||
for _, doc := range idx.docs {
|
||||
seen := make(map[string]bool, len(doc.tf))
|
||||
for term := range doc.tf {
|
||||
if !seen[term] {
|
||||
idx.df[term]++
|
||||
seen[term] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// Search returns actions ranked by BM25 relevance to the query.
|
||||
// An empty query returns all actions (useful for browsing).
|
||||
func (idx *Index) Search(query string, limit int) []SearchResult {
|
||||
if limit <= 0 {
|
||||
limit = idx.n
|
||||
}
|
||||
|
||||
// Empty query: return all actions with score 0.
|
||||
queryTerms := tokenizeQuery(query)
|
||||
if len(queryTerms) == 0 {
|
||||
results := make([]SearchResult, len(idx.actions))
|
||||
for i, a := range idx.actions {
|
||||
results[i] = SearchResult{Action: a, Score: 0}
|
||||
}
|
||||
if limit < len(results) {
|
||||
return results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Score each document.
|
||||
type scored struct {
|
||||
index int
|
||||
score float64
|
||||
}
|
||||
var candidates []scored
|
||||
|
||||
for i, doc := range idx.docs {
|
||||
score := idx.bm25Score(doc, queryTerms)
|
||||
if score > 0 {
|
||||
candidates = append(candidates, scored{index: i, score: score})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(candidates, func(a, b int) bool {
|
||||
return candidates[a].score > candidates[b].score
|
||||
})
|
||||
|
||||
if limit < len(candidates) {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
|
||||
results := make([]SearchResult, len(candidates))
|
||||
for i, c := range candidates {
|
||||
results[i] = SearchResult{
|
||||
Action: idx.actions[c.index],
|
||||
Score: c.score,
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// bm25Score computes the BM25 score for a document against query terms.
|
||||
func (idx *Index) bm25Score(doc document, queryTerms []string) float64 {
|
||||
dl := float64(len(doc.terms))
|
||||
score := 0.0
|
||||
|
||||
for _, qt := range queryTerms {
|
||||
tfVal := float64(doc.tf[qt])
|
||||
if tfVal == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
nq := float64(idx.df[qt])
|
||||
// IDF: ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1)
|
||||
idf := math.Log((float64(idx.n)-nq+0.5)/(nq+0.5) + 1)
|
||||
|
||||
// BM25 TF component
|
||||
num := tfVal * (bm25K1 + 1)
|
||||
denom := tfVal + bm25K1*(1-bm25B+bm25B*dl/idx.avgDL)
|
||||
score += idf * num / denom
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// tokenizeAction builds a searchable token list from an action's fields.
|
||||
// The action name is included both as a compound token (e.g. "send_message"
|
||||
// preserved as-is) and as split tokens. This ensures that an exact-name query
|
||||
// like "send_message" strongly favours the action with that exact name over
|
||||
// actions that merely contain the same sub-words.
|
||||
func tokenizeAction(a Action) []string {
|
||||
var tokens []string
|
||||
|
||||
// Add the full action name as a compound token (lowercased but not split).
|
||||
// Repeat to boost exact-name matches.
|
||||
compound := strings.ToLower(a.Name)
|
||||
for i := 0; i < 5; i++ {
|
||||
tokens = append(tokens, compound)
|
||||
}
|
||||
|
||||
// Also add the split tokens from the name.
|
||||
for i := 0; i < 3; i++ {
|
||||
tokens = append(tokens, tokenize(a.Name)...)
|
||||
}
|
||||
|
||||
// Index remaining fields normally.
|
||||
var parts []string
|
||||
parts = append(parts, a.Category)
|
||||
parts = append(parts, a.Description)
|
||||
parts = append(parts, a.Returns)
|
||||
|
||||
for _, p := range a.Params {
|
||||
parts = append(parts, p.Name)
|
||||
parts = append(parts, p.Description)
|
||||
}
|
||||
for _, ex := range a.Examples {
|
||||
parts = append(parts, ex.Description)
|
||||
}
|
||||
|
||||
tokens = append(tokens, tokenize(strings.Join(parts, " "))...)
|
||||
return tokens
|
||||
}
|
||||
|
||||
// tokenizeQuery produces query tokens. It includes the standard split tokens
|
||||
// plus any compound tokens (underscore-joined words) found in the original
|
||||
// query. This allows "send_message" to match the compound name token.
|
||||
func tokenizeQuery(text string) []string {
|
||||
tokens := tokenize(text)
|
||||
|
||||
// Also add compound tokens for underscore-joined words in the query.
|
||||
lower := strings.ToLower(text)
|
||||
words := strings.Fields(lower)
|
||||
for _, w := range words {
|
||||
if strings.Contains(w, "_") {
|
||||
// The compound token is the full underscore-joined word, lowered.
|
||||
// Strip non-alphanumeric/underscore chars from edges.
|
||||
cleaned := strings.TrimFunc(w, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_'
|
||||
})
|
||||
if cleaned != "" {
|
||||
tokens = append(tokens, cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// tokenize splits text into lowercase tokens, splitting on whitespace and
|
||||
// punctuation, then applies simple stemming to normalize plurals.
|
||||
func tokenize(text string) []string {
|
||||
text = strings.ToLower(text)
|
||||
words := strings.FieldsFunc(text, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
var out []string
|
||||
for _, w := range words {
|
||||
if len(w) > 0 {
|
||||
out = append(out, simpleStem(w))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// simpleStem applies minimal suffix stripping so that plurals and common
|
||||
// inflections match their base forms (e.g. "channels" -> "channel",
|
||||
// "messages" -> "message"). This is intentionally simple — no external
|
||||
// stemming library is needed for ~23 documents.
|
||||
func simpleStem(w string) string {
|
||||
// Order matters: try longer suffixes first.
|
||||
if strings.HasSuffix(w, "sses") {
|
||||
// "addresses" -> "address"
|
||||
return w
|
||||
}
|
||||
if strings.HasSuffix(w, "ies") && len(w) > 4 {
|
||||
return w[:len(w)-3] + "y"
|
||||
}
|
||||
if strings.HasSuffix(w, "es") && len(w) > 3 {
|
||||
base := w[:len(w)-2]
|
||||
// Only strip -es after s, x, z, ch, sh (English rule).
|
||||
if strings.HasSuffix(base, "s") || strings.HasSuffix(base, "x") ||
|
||||
strings.HasSuffix(base, "z") || strings.HasSuffix(base, "ch") ||
|
||||
strings.HasSuffix(base, "sh") {
|
||||
return base
|
||||
}
|
||||
// Otherwise strip just the trailing 's'.
|
||||
return w[:len(w)-1]
|
||||
}
|
||||
if strings.HasSuffix(w, "s") && len(w) > 3 && !strings.HasSuffix(w, "ss") {
|
||||
return w[:len(w)-1]
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchRelevantResults(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantNames []string // expected action names in results (order-independent)
|
||||
}{
|
||||
{
|
||||
name: "send message finds send_message and send_channel_message",
|
||||
query: "send message",
|
||||
wantNames: []string{"send_message", "send_channel_message"},
|
||||
},
|
||||
{
|
||||
name: "inbox finds read_inbox",
|
||||
query: "inbox",
|
||||
wantNames: []string{"read_inbox"},
|
||||
},
|
||||
{
|
||||
name: "channel finds channel actions",
|
||||
query: "channel",
|
||||
wantNames: []string{"create_channel", "join_channel", "leave_channel", "list_channels"},
|
||||
},
|
||||
{
|
||||
name: "task finds swarm actions",
|
||||
query: "task",
|
||||
wantNames: []string{"post_task", "bid_task", "complete_task", "list_tasks"},
|
||||
},
|
||||
{
|
||||
name: "attachment finds attachment actions",
|
||||
query: "attachment",
|
||||
wantNames: []string{"upload_attachment", "download_attachment"},
|
||||
},
|
||||
{
|
||||
name: "bid finds bid_task",
|
||||
query: "bid",
|
||||
wantNames: []string{"bid_task", "accept_bid"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := idx.Search(tt.query, 0)
|
||||
resultNames := make(map[string]bool, len(results))
|
||||
for _, r := range results {
|
||||
resultNames[r.Action.Name] = true
|
||||
}
|
||||
for _, want := range tt.wantNames {
|
||||
if !resultNames[want] {
|
||||
t.Errorf("query %q: expected %q in results, got %v", tt.query, want, nameList(results))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchExactNameRanksHigher(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
// "send_message" as a query should rank send_message above send_channel_message
|
||||
results := idx.Search("send_message", 0)
|
||||
if len(results) < 2 {
|
||||
t.Fatalf("expected at least 2 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// The top result should be send_message (exact name match).
|
||||
if results[0].Action.Name != "send_message" {
|
||||
t.Errorf("expected send_message as top result, got %q", results[0].Action.Name)
|
||||
}
|
||||
|
||||
// Verify score ordering.
|
||||
if results[0].Score <= results[1].Score {
|
||||
t.Errorf("expected top result score (%f) > second result score (%f)",
|
||||
results[0].Score, results[1].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyQueryReturnsAll(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
results := idx.Search("", 0)
|
||||
if len(results) != 23 {
|
||||
t.Errorf("empty query: expected 23 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// All scores should be 0 for empty query.
|
||||
for _, res := range results {
|
||||
if res.Score != 0 {
|
||||
t.Errorf("empty query: expected score 0 for %q, got %f", res.Action.Name, res.Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLimitRespected(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
limit int
|
||||
}{
|
||||
{"message", 1},
|
||||
{"channel", 3},
|
||||
{"", 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
results := idx.Search(tt.query, tt.limit)
|
||||
if len(results) > tt.limit {
|
||||
t.Errorf("query %q limit %d: got %d results", tt.query, tt.limit, len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchNoResults(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
results := idx.Search("xyzzyplugh", 10)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("nonsense query: expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchScoresPositive(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
results := idx.Search("send message agent", 10)
|
||||
for _, res := range results {
|
||||
if res.Score <= 0 {
|
||||
t.Errorf("non-empty query matched %q with non-positive score %f",
|
||||
res.Action.Name, res.Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescending(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
idx := NewIndex(r.List())
|
||||
|
||||
results := idx.Search("message", 0)
|
||||
for i := 1; i < len(results); i++ {
|
||||
if results[i].Score > results[i-1].Score {
|
||||
t.Errorf("results not sorted descending: score[%d]=%f > score[%d]=%f",
|
||||
i, results[i].Score, i-1, results[i-1].Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenize(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"send_message", []string{"send", "message"}},
|
||||
{"Hello, World!", []string{"hello", "world"}},
|
||||
{"JSON metadata object (optional)", []string{"json", "metadata", "object", "optional"}},
|
||||
{"", nil},
|
||||
{" spaces ", []string{"space"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := tokenize(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("tokenize(%q): got %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("tokenize(%q)[%d]: got %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIndexEmpty(t *testing.T) {
|
||||
idx := NewIndex(nil)
|
||||
results := idx.Search("anything", 10)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("empty index: expected 0 results, got %d", len(results))
|
||||
}
|
||||
|
||||
results = idx.Search("", 10)
|
||||
if len(results) != 0 {
|
||||
t.Errorf("empty index empty query: expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// nameList is a test helper that extracts action names from search results.
|
||||
func nameList(results []SearchResult) []string {
|
||||
names := make([]string, len(results))
|
||||
for i, r := range results {
|
||||
names[i] = r.Action.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package actions
|
||||
|
||||
// Action represents a callable operation in the system.
|
||||
type Action struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // messaging, channels, swarm, attachments
|
||||
Description string `json:"description"`
|
||||
Params []Param `json:"params"`
|
||||
Returns string `json:"returns"` // Human-readable return description
|
||||
Examples []Example `json:"examples"`
|
||||
}
|
||||
|
||||
// Param describes an action parameter.
|
||||
type Param struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // string, number, boolean
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Default string `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Example shows a usage example for the action.
|
||||
type Example struct {
|
||||
Description string `json:"description"`
|
||||
Code string `json:"code"` // JS code example using call()
|
||||
}
|
||||
|
||||
// SearchResult is an action with a relevance score.
|
||||
type SearchResult struct {
|
||||
Action Action `json:"action"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
Reference in New Issue
Block a user